diff --git a/.gitignore b/.gitignore index 1dc4252e0c..2e636f16f3 100644 --- a/.gitignore +++ b/.gitignore @@ -3,11 +3,18 @@ packages/*/build packages/eas-cli/dist packages/eas-cli/tmp scripts/build +dist +dist_* + +# TypeScript +**/*.tsbuildinfo # Code editors .idea .history .vscode +*.swp +*.swo # macOS .DS_Store diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000000..10ebc92a90 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,6 @@ +# AGENTS.md + +When working with this repository follow instructions from CLAUDE.md. + +- [./CLAUDE.md](./CLAUDE.md) + diff --git a/CLAUDE.md b/CLAUDE.md index f44009039a..8568dedfe2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,7 +4,9 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ## Monorepo Structure -This is a **Lerna-based monorepo** with four packages: +This is a **Lerna-based monorepo** containing the EAS CLI and all supporting build libraries. + +### CLI & Configuration Packages 1. **[packages/eas-cli](./packages/eas-cli/CLAUDE.md)** - The main CLI tool (published as `eas-cli`) - 95+ commands organized by domain (build, submit, update, channel, credentials, etc.) @@ -19,12 +21,60 @@ This is a **Lerna-based monorepo** with four packages: 3. **[packages/eas-build-cache-provider](./packages/eas-build-cache-provider/CLAUDE.md)** - Build cache plugin (published as `eas-build-cache-provider`) - Optimizes build caching for Expo CLI -4. **[packages/worker](./packages/worker/CLAUDE.md)** - Turtle Worker service (private, not published) +### Build Execution Packages + +4. **[packages/eas-build-job](./packages/eas-build-job)** - Build job definitions (published as `@expo/eas-build-job`) + - Defines all data structures for build operations (Job, BuildPhase, BuildMode, Platform, Workflow) + - Provides type definitions and validation schemas (Zod, Joi) + - Contains BuildPhase enum that defines the traditional build pipeline stages + - Key exports: `Job`, `BuildPhase`, `BuildMode`, `BuildTrigger`, `Workflow`, `ArchiveSource` + - **Foundation that all other build packages depend on** + +5. **[packages/build-tools](./packages/build-tools)** - Build execution engine (published as `@expo/build-tools`) + - Orchestrates all build operations through `BuildContext` + - Contains platform-specific builders: `androidBuilder()`, `iosBuilder()`, `runCustomBuildAsync()` + - Manages build phases, artifact uploading, caching, credentials + - Provides functions for custom builds + - Integrates with GraphQL API + +6. **[packages/steps](./packages/steps)** - Custom build workflow engine (published as `@expo/steps`, ESM module) + - Framework for defining and executing custom build steps + - Key abstractions: + - `BuildWorkflow`: Orchestrates sequential step execution + - `BuildStep`: Individual executable unit with inputs/outputs + - `BuildStepGlobalContext`: Manages shared state and interpolation + - `BuildStepContext`: Per-step execution context + - Supports conditional execution with `if` expressions (using jsep) + - Template interpolation: `${{ steps.step-id.outputs.outputName }}` + - Parses build configs from YAML/JSON + +7. **[packages/local-build-plugin](./packages/local-build-plugin)** - Local build execution (published as `eas-cli-local-build-plugin`) + - Allows running EAS builds locally on developer machines + - Entry point: `packages/local-build-plugin/src/main.ts` + - Sets `EAS_BUILD_RUNNER=local-build-plugin` environment variable + - Reuses all build-tools logic for consistency with cloud builds + +8. **[packages/worker](./packages/worker/CLAUDE.md)** - Turtle Worker service (private, not published) - Runs on EAS build VMs/pods - WebSocket server for communication with Turtle Launcher - Wraps `@expo/build-tools` to execute actual React Native builds -> **Note**: Each package has its own CLAUDE.md with package-specific guidance. Click the links above to view them. +### Supporting Packages + +9. **[packages/logger](./packages/logger)** - Bunyan-based structured logging (published as `@expo/logger`) + - Used by all build packages + +10. **[packages/downloader](./packages/downloader)** - HTTP file downloading with retry logic (published as `@expo/downloader`) + +11. **[packages/turtle-spawn](./packages/turtle-spawn)** - Child process spawning with error handling (published as `@expo/turtle-spawn`) + +12. **[packages/template-file](./packages/template-file)** - Lodash-based template string interpolation (published as `@expo/template-file`) + +13. **[packages/create-eas-build-function](./packages/create-eas-build-function)** - CLI scaffolding tool for custom build functions (published as `create-eas-build-function`) + +14. **[packages/expo-cocoapods-proxy](./packages/expo-cocoapods-proxy)** - Ruby gem for CocoaPods proxy (published as `expo-cocoapods-proxy` gem) + +> **Note**: Some packages have their own CLAUDE.md with package-specific guidance. Click the links above to view them. ## Common Development Commands @@ -52,6 +102,8 @@ yarn test --watch # Run tests in watch mode cd packages/eas-cli && yarn test cd packages/eas-json && yarn test cd packages/worker && yarn test +cd packages/build-tools && yarn jest-unit +cd packages/steps && yarn test ``` ### Type Checking & Linting @@ -73,6 +125,20 @@ alias easd="$(pwd)/packages/eas-cli/bin/run" easd build --help ``` +### Local Build Testing + +Set up environment variables for testing local builds: + +```bash +export EAS_LOCAL_BUILD_PLUGIN_PATH=$HOME/expo/eas-cli/bin/eas-cli-local-build-plugin +export EAS_LOCAL_BUILD_WORKINGDIR=$HOME/expo/eas-build-workingdir +export EAS_LOCAL_BUILD_SKIP_CLEANUP=1 +export EAS_LOCAL_BUILD_ARTIFACTS_DIR=$HOME/expo/eas-build-workingdir/results + +# Then run build with --local flag in eas-cli +eas build --local +``` + ## Build System Each package has independent TypeScript compilation: @@ -80,12 +146,86 @@ Each package has independent TypeScript compilation: - `eas-json`: `src/` → `build/` - `eas-build-cache-provider`: `src/` → `build/` - `worker`: `src/` → `dist/` +- `build-tools`: `src/` → `dist/` +- `eas-build-job`: `src/` → `dist/` +- `steps`: `src/` → `dist_esm/` and `dist_commonjs/` +- `local-build-plugin`: `src/` → `dist/` +- Other packages: `src/` → `dist/` TypeScript configs: - `tsconfig.json` - Base configuration (extends @tsconfig/node18) - `tsconfig.build.json` - Production builds - `tsconfig.allowUnused.json` - Development with relaxed rules +## Key Architectural Patterns + +### Build Phases + +Most traditional build operations are wrapped in phases for tracking: + +```typescript +await ctx.runBuildPhase(BuildPhase.INSTALL_DEPENDENCIES, async () => { + // Phase logic here +}); +``` + +Phases can be marked as skipped, warning, or failed for granular reporting. + +### Context Objects + +- **BuildContext** (`build-tools`): For traditional builds, wraps Job, manages phases/artifacts/caching +- **CustomBuildContext** (`build-tools`): Implements `ExternalBuildContextProvider`, bridges BuildContext to steps framework, used in custom builds and generic jobs +- **BuildStepGlobalContext** (`steps`): Manages step outputs, interpolation, shared state +- **BuildStepContext** (`steps`): Per-step context with working directory and logger + +### Custom Build Steps + +Steps are defined with: + +- `id`: Unique identifier +- `name`: Display name +- `run`: Command or function reference +- `if`: Optional condition (`${{ always() }}`, `${{ success() }}`, etc.) +- `inputs`: Key-value inputs to the step +- `outputs`: Named outputs accessible to later steps + +Built-in step functions are in `packages/build-tools/src/steps/functions/` + +### Conditional Execution + +Uses jsep for expression evaluation: + +```yaml +if: ${{ steps.previous_step.outputs.success == 'true' && env.ENVIRONMENT == 'production' }} +``` + +### Artifact Management + +Artifacts tracked as `ArtifactToUpload`: + +- Managed artifacts (APK, IPA, AAB) with specific handling +- Generic artifacts for any file +- Upload via `ctx.uploadArtifact()` or `upload-artifact` step + +## Package Interdependencies + +``` +eas-cli → @expo/eas-json + → @expo/build-tools → @expo/eas-build-job + → @expo/steps → @expo/eas-build-job + → @expo/logger + → @expo/turtle-spawn + → @expo/downloader + → @expo/template-file + +local-build-plugin → @expo/build-tools → @expo/eas-build-job + → @expo/turtle-spawn + +worker → @expo/build-tools +``` + +Most packages depend on `@expo/eas-build-job` as the source of truth for types. + ## Testing Architecture - **Framework**: Jest with multi-project configuration @@ -103,6 +243,31 @@ yarn test yarn test packages/eas-cli/src/project/__tests__/projectUtils-test.ts ``` +## Common Development Scenarios + +### Adding a Built-in Step Function + +1. Create file in `packages/build-tools/src/steps/functions/yourFunction.ts` +2. Export `createYourFunctionBuildFunction()` following existing patterns +3. Add to `getEasFunctions()` in `packages/build-tools/src/steps/functions/easFunctions.ts` +4. Function receives `BuildStepContext` and input/output maps + +### Adding Error Detection + +1. Add pattern detection in `packages/build-tools/src/buildErrors/detectError.ts` +2. Implement resolver for better error messages +3. Helps users understand and fix build failures + +### Working with Platform-Specific Builders + +- **Android builder** (`packages/build-tools/src/builders/android.ts`): Gradle-based, handles APK/AAB generation, also see `functionGroups/build.ts` +- **iOS builder** (`packages/build-tools/src/builders/ios.ts`): Fastlane/Xcode-based, handles IPA generation, also see `functionGroups/build.ts` +- Both use `runBuilderWithHooksAsync()` which runs build result hooks (on-success, on-error, on-complete) from package.json + +### Introducing Breaking Changes to Job API (@expo/eas-build-job) + +If you want to introduce breaking changes to the `@expo/eas-build-job` package, contact one of the CODEOWNERS to coordinate changes with EAS build servers and GraphQL API. Describe what changes you want to make and why. After everything is deployed to production, you can introduce a PR that relies on the new implementation. + ## Environment Variables ```bash @@ -134,6 +299,12 @@ Log.warn('Warning message'); Log.error('Error message'); ``` +### Commit Messages +- When possible, prepend commit messages with `[PACKAGE-BEING-CHANGED]`, e.g. `[steps] Add new step function` +- Do not use prefixes like `chore:` and `feat:` +- Commit messages should be concise. Only complex changes should be longer than one line +- Commit changes in logical groups + ## Release Process 1. Update `CHANGELOG.md` in the appropriate package @@ -144,8 +315,16 @@ Log.error('Error message'); 3. GitHub Actions workflow handles release automation 4. Notifications sent to Slack #eas-cli channel +## Licensing + +This repository contains packages under different licenses: +- **MIT License**: `eas-cli`, `@expo/eas-json`, `@expo/eas-build-job`, `eas-build-cache-provider` +- **BUSL-1.1 (Business Source License)**: `@expo/build-tools`, `@expo/steps`, `@expo/logger`, `@expo/downloader`, `@expo/turtle-spawn`, `@expo/template-file`, `eas-cli-local-build-plugin` + +See `LICENSE` (MIT) and `LICENSE-BUSL` (BUSL-1.1) for details. + ## Important Notes - **Node Version**: Requires Node.js >= 18.0.0 (managed via Volta) - **Package Manager**: Uses Yarn 1.22.21 -- **Compilation Target**: CommonJS with Node resolution +- **Compilation Target**: CommonJS with Node resolution (except `@expo/steps` which is ESM) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 2be666b1f4..be03a3280b 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -65,7 +65,7 @@ For development against staging API: ## Working on local builds (`eas build --local`) -See https://github.com/expo/eas-build/blob/main/DEVELOPMENT.md for how to set up your environment when making changes to [`eas-cli-local-build-plugin`](https://github.com/expo/eas-build/tree/main/packages/local-build-plugin) and/or [`build-tools`](https://github.com/expo/eas-build/tree/main/packages/build-tools). +See [`CLAUDE.md`](./CLAUDE.md) for how to set up your environment when making changes to [`eas-cli-local-build-plugin`](./packages/local-build-plugin) and/or [`build-tools`](./packages/build-tools). ## Testing diff --git a/LICENSE-BUSL b/LICENSE-BUSL new file mode 100644 index 0000000000..6e4bae90d6 --- /dev/null +++ b/LICENSE-BUSL @@ -0,0 +1,100 @@ +Business Source License 1.1 + +Parameters + +Licensor: 650 Industries, Inc. +Licensed Work: EAS Build + The Licensed Work is (c) 2021 650 Industries, Inc. +Additional Use Grant: You may make use of the Licensed Work, provided that you do + not use the Licensed Work for commercial offerings such as + a CI/CD service or application build service that allows + third parties (other than your employees and contractors) + to access the functionality of and directly benefit from the + functionality of the Licensed Work. + +Change Date: 2028-04-01 + +Change License: MIT + +For information about alternative licensing arrangements for the Software, +please visit: https://expo.dev/pricing/ + +Notice + +The Business Source License (this document, or the "License") is not an Open +Source license. However, the Licensed Work will eventually be made available +under an Open Source License, as stated in this License. + +License text copyright (c) 2017 MariaDB Corporation Ab, All Rights Reserved. +"Business Source License" is a trademark of MariaDB Corporation Ab. + +----------------------------------------------------------------------------- + +Business Source License 1.1 + +Terms + +The Licensor hereby grants you the right to copy, modify, create derivative +works, redistribute, and make non-production use of the Licensed Work. The +Licensor may make an Additional Use Grant, above, permitting limited +production use. + +Effective on the Change Date, or the fourth anniversary of the first publicly +available distribution of a specific version of the Licensed Work under this +License, whichever comes first, the Licensor hereby grants you rights under +the terms of the Change License, and the rights granted in the paragraph +above terminate. + +If your use of the Licensed Work does not comply with the requirements +currently in effect as described in this License, you must purchase a +commercial license from the Licensor, its affiliated entities, or authorized +resellers, or you must refrain from using the Licensed Work. + +All copies of the original and modified Licensed Work, and derivative works +of the Licensed Work, are subject to this License. This License applies +separately for each version of the Licensed Work and the Change Date may vary +for each version of the Licensed Work released by Licensor. + +You must conspicuously display this License on each original or modified copy +of the Licensed Work. If you receive the Licensed Work in original or +modified form from a third party, the terms and conditions set forth in this +License apply to your use of that work. + +Any use of the Licensed Work in violation of this License will automatically +terminate your rights under this License for the current and all other +versions of the Licensed Work. + +This License does not grant you any right in any trademark or logo of +Licensor or its affiliates (provided that you may use a trademark or logo of +Licensor as expressly required by this License). + +TO THE EXTENT PERMITTED BY APPLICABLE LAW, THE LICENSED WORK IS PROVIDED ON +AN "AS IS" BASIS. LICENSOR HEREBY DISCLAIMS ALL WARRANTIES AND CONDITIONS, +EXPRESS OR IMPLIED, INCLUDING (WITHOUT LIMITATION) WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, NON-INFRINGEMENT, AND +TITLE. + +MariaDB hereby grants you permission to use this License’s text to license +your works, and to refer to it using the trademark "Business Source License", +as long as you comply with the Covenants of Licensor below. + +Covenants of Licensor + +In consideration of the right to use this License’s text and the "Business +Source License" name and trademark, Licensor covenants to MariaDB, and to all +other recipients of the licensed work to be provided by Licensor: + +1. To specify as the Change License the GPL Version 2.0 or any later version, + or a license that is compatible with GPL Version 2.0 or a later version, + where "compatible" means that software provided under the Change License can + be included in a program with software provided under GPL Version 2.0 or a + later version. Licensor may specify additional Change Licenses without + limitation. + +2. To either: (a) specify an additional grant of rights to use that does not + impose any additional restriction on the right granted in this License, as + the Additional Use Grant; or (b) insert the text "None". + +3. To specify a Change Date. + +4. Not to modify this License in any other way. diff --git a/LICENSE-eas-build-job b/LICENSE-eas-build-job new file mode 100644 index 0000000000..1562b6cb16 --- /dev/null +++ b/LICENSE-eas-build-job @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2020-present 650 Industries, Inc. (aka Expo) + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/bin/eas-cli-local-build-plugin b/bin/eas-cli-local-build-plugin new file mode 120000 index 0000000000..859e76b790 --- /dev/null +++ b/bin/eas-cli-local-build-plugin @@ -0,0 +1 @@ +../packages/local-build-plugin/bin/run \ No newline at end of file diff --git a/packages/build-tools/.eslintrc.json b/packages/build-tools/.eslintrc.json new file mode 100644 index 0000000000..be97c53fbb --- /dev/null +++ b/packages/build-tools/.eslintrc.json @@ -0,0 +1,3 @@ +{ + "extends": "../../.eslintrc.json" +} diff --git a/packages/build-tools/.gitignore b/packages/build-tools/.gitignore new file mode 100644 index 0000000000..d46f30edf9 --- /dev/null +++ b/packages/build-tools/.gitignore @@ -0,0 +1,9 @@ +node_modules/ +dist/ + +.secrets* +!.secrets.example +!.secrets.test + +src/graphql-env.d.ts +schema.graphql diff --git a/packages/build-tools/README.md b/packages/build-tools/README.md new file mode 100644 index 0000000000..bf5acbcb71 --- /dev/null +++ b/packages/build-tools/README.md @@ -0,0 +1,7 @@ +# @expo/build-tools + +`@expo/build-tools` is the core library for the EAS Build service. It implements the build process for React Native projects and for managed Expo applications. + +## Repository + +https://github.com/expo/eas-cli/tree/main/packages/build-tools diff --git a/packages/build-tools/bin/set-env b/packages/build-tools/bin/set-env new file mode 100755 index 0000000000..9cfbe9f5d3 --- /dev/null +++ b/packages/build-tools/bin/set-env @@ -0,0 +1,23 @@ +#!/usr/bin/env bash + +set -eo pipefail + +NAME=$1 +VALUE=$2 + +if [[ -z "$__EAS_BUILD_ENVS_DIR" ]]; then + echo "Set __EAS_BUILD_ENVS_DIR" + exit 1 +fi + +if [[ -z "$NAME" || -z "$VALUE" ]]; then + echo "Usage: set-env NAME VALUE" + exit 2 +fi + +if [[ "$NAME" == *"="* ]]; then + echo "Environment name can't include =" + exit 1 +fi + +echo -n $VALUE > $__EAS_BUILD_ENVS_DIR/$NAME diff --git a/packages/build-tools/jest/integration-config.ts b/packages/build-tools/jest/integration-config.ts new file mode 100644 index 0000000000..2d9f9ae269 --- /dev/null +++ b/packages/build-tools/jest/integration-config.ts @@ -0,0 +1,12 @@ +import type { Config } from 'jest'; + +const config: Config = { + preset: 'ts-jest', + testEnvironment: 'node', + rootDir: '../src', + testMatch: ['**/__integration-tests__/*.test.ts'], + clearMocks: true, + setupFilesAfterEnv: ['/../jest/setup-tests.ts'], +}; + +export default config; diff --git a/packages/build-tools/jest/setup-tests.ts b/packages/build-tools/jest/setup-tests.ts new file mode 100644 index 0000000000..5618d66ad6 --- /dev/null +++ b/packages/build-tools/jest/setup-tests.ts @@ -0,0 +1,21 @@ +import os from 'node:os'; + +import { vol, fs } from 'memfs'; + +if (process.env.NODE_ENV !== 'test') { + throw new Error("NODE_ENV environment variable must be set to 'test'."); +} + +// Always mock: +jest.mock('fs'); +jest.mock('node:fs', () => fs); +jest.mock('fs/promises'); +jest.mock('node:fs/promises', () => fs.promises); +jest.mock('node-fetch'); + +beforeEach(() => { + vol.reset(); + vol.fromNestedJSON({ + [os.tmpdir()]: {}, + }); +}); diff --git a/packages/build-tools/jest/unit-config.ts b/packages/build-tools/jest/unit-config.ts new file mode 100644 index 0000000000..8c7bc16a17 --- /dev/null +++ b/packages/build-tools/jest/unit-config.ts @@ -0,0 +1,15 @@ +import type { Config } from 'jest'; + +const config: Config = { + preset: 'ts-jest', + testEnvironment: 'node', + rootDir: '../src', + testMatch: [ + '**/__tests__/*.test.ts', + ...(process.platform === 'darwin' ? ['**/__tests__/*.test.ios.ts'] : []), + ], + clearMocks: true, + setupFilesAfterEnv: ['/../jest/setup-tests.ts'], +}; + +export default config; diff --git a/packages/build-tools/package.json b/packages/build-tools/package.json new file mode 100644 index 0000000000..a7027b547c --- /dev/null +++ b/packages/build-tools/package.json @@ -0,0 +1,94 @@ +{ + "name": "@expo/build-tools", + "repository": { + "type": "git", + "url": "https://github.com/expo/eas-cli.git", + "directory": "packages/build-tools" + }, + "version": "1.0.260", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "files": [ + "dist", + "resources", + "templates", + "bin" + ], + "scripts": { + "start": "yarn watch", + "prewatch": "yarn gql", + "watch": "tsc --watch --preserveWatchOutput", + "prebuild": "yarn gql", + "build": "tsc", + "prepack": "rm -rf dist && tsc -p tsconfig.build.json", + "jest-unit": "jest --config jest/unit-config.ts", + "jest-integration": "jest --config jest/integration-config.ts", + "jest-unit-watch": "jest --config jest/unit-config.ts --watch", + "jest-integration-watch": "jest --config jest/integration-config.ts --watch", + "clean": "rm -rf node_modules dist coverage", + "gql": "gql.tada generate-schema ${API_SERVER_URL:-https://api.expo.dev}/graphql --output ./schema.graphql && gql.tada generate-output", + "gql:local": "API_SERVER_URL=http://api.expo.test yarn gql", + "test": "yarn jest-unit" + }, + "author": "Expo ", + "bugs": "https://github.com/expo/eas-cli/issues", + "license": "BUSL-1.1", + "dependencies": { + "@expo/config": "10.0.6", + "@expo/config-plugins": "9.0.12", + "@expo/downloader": "1.0.260", + "@expo/eas-build-job": "1.0.260", + "@expo/env": "^0.4.0", + "@expo/logger": "1.0.260", + "@expo/package-manager": "1.7.0", + "@expo/plist": "^0.2.0", + "@expo/results": "^1.0.0", + "@expo/steps": "1.0.260", + "@expo/template-file": "1.0.260", + "@expo/turtle-spawn": "1.0.260", + "@expo/xcpretty": "^4.3.1", + "@google-cloud/storage": "^7.11.2", + "@urql/core": "^6.0.1", + "fast-glob": "^3.3.2", + "fs-extra": "^11.2.0", + "gql.tada": "^1.8.13", + "joi": "^17.13.1", + "lodash": "^4.17.21", + "node-fetch": "^2.7.0", + "node-forge": "^1.3.1", + "nullthrows": "^1.1.1", + "plist": "^3.1.0", + "promise-limit": "^2.7.0", + "promise-retry": "^2.0.1", + "resolve-from": "^5.0.0", + "retry": "^0.13.1", + "semver": "^7.6.2", + "tar": "^7.4.3", + "yaml": "^2.8.1" + }, + "devDependencies": { + "@expo/repack-app": "~0.2.5", + "@types/fs-extra": "^11.0.4", + "@types/jest": "^29.5.12", + "@types/lodash": "^4.17.4", + "@types/node": "20.14.2", + "@types/node-fetch": "^2.6.11", + "@types/node-forge": "^1.3.11", + "@types/plist": "^3.0.5", + "@types/promise-retry": "^1.1.6", + "@types/retry": "^0.12.5", + "@types/semver": "^7.5.8", + "@types/uuid": "^9.0.8", + "jest": "^29.7.0", + "memfs": "^4.17.1", + "ts-jest": "^29.1.4", + "ts-mockito": "^2.6.1", + "tslib": "^2.6.3", + "typescript": "^5.5.4", + "uuid": "^9.0.1" + }, + "volta": { + "node": "20.14.0", + "yarn": "1.22.21" + } +} diff --git a/packages/build-tools/src/__mocks__/@expo/logger.ts b/packages/build-tools/src/__mocks__/@expo/logger.ts new file mode 100644 index 0000000000..bf40881a8a --- /dev/null +++ b/packages/build-tools/src/__mocks__/@expo/logger.ts @@ -0,0 +1,21 @@ +import bunyan from 'bunyan'; + +export function createLogger(): bunyan { + const logger = { + info: () => {}, + debug: () => {}, + error: () => {}, + warn: () => {}, + child: (_fields) => logger, + } as bunyan; + return logger; +} + +export enum LoggerLevel { + TRACE = 'trace', + DEBUG = 'debug', + INFO = 'info', + WARN = 'warn', + ERROR = 'error', + FATAL = 'fatal', +} diff --git a/packages/build-tools/src/__mocks__/fs.ts b/packages/build-tools/src/__mocks__/fs.ts new file mode 100644 index 0000000000..d5c3ad53d9 --- /dev/null +++ b/packages/build-tools/src/__mocks__/fs.ts @@ -0,0 +1,21 @@ +import { fs } from 'memfs'; + +const fsRealpath = fs.realpath; +(fsRealpath as any).native = fsRealpath; + +const fsRm = ( + path: string, + options: object, + callback: (err: NodeJS.ErrnoException | null) => void +): void => { + fs.promises + .rm(path, options) + .then(() => { + callback(null); + }) + .catch((err) => { + callback(err); + }); +}; + +module.exports = { ...fs, realpath: fsRealpath, rm: fsRm }; diff --git a/packages/build-tools/src/__mocks__/fs/promises.ts b/packages/build-tools/src/__mocks__/fs/promises.ts new file mode 100644 index 0000000000..1f94062b12 --- /dev/null +++ b/packages/build-tools/src/__mocks__/fs/promises.ts @@ -0,0 +1,10 @@ +import { fs } from 'memfs'; + +const fsRealpath = fs.realpath; +(fsRealpath as any).native = fsRealpath; + +const fsRm = (path: string, options: object): Promise => { + return fs.promises.rm(path, options); +}; + +module.exports = { ...fs.promises, realpath: fsRealpath, rm: fsRm }; diff --git a/packages/build-tools/src/__tests__/context.test.ts b/packages/build-tools/src/__tests__/context.test.ts new file mode 100644 index 0000000000..5e89eff064 --- /dev/null +++ b/packages/build-tools/src/__tests__/context.test.ts @@ -0,0 +1,121 @@ +import { randomUUID } from 'crypto'; + +import { BuildTrigger, Job, Metadata } from '@expo/eas-build-job'; +import { vol } from 'memfs'; + +import { BuildContext } from '../context'; + +import { createMockLogger } from './utils/logger'; + +jest.mock('fs'); +jest.mock('fs-extra'); + +describe('BuildContext', () => { + it('should merge secrets', async () => { + const robotAccessToken = randomUUID(); + await vol.promises.mkdir('/workingdir/eas-environment-secrets/', { recursive: true }); + + const ctx = new BuildContext( + { + triggeredBy: BuildTrigger.GIT_BASED_INTEGRATION, + secrets: { + robotAccessToken, + environmentSecrets: [ + { + name: 'TEST_SECRET', + value: 'test-secret-value', + }, + ], + }, + } as Job, + { + env: { + __API_SERVER_URL: 'http://api.expo.test', + }, + workingdir: '/workingdir', + logger: createMockLogger(), + logBuffer: { getLogs: () => [], getPhaseLogs: () => [] }, + uploadArtifact: jest.fn(), + } + ); + + ctx.updateJobInformation({} as Job, {} as Metadata); + + expect(ctx.job.secrets).toEqual({ + robotAccessToken, + environmentSecrets: [ + { + name: 'TEST_SECRET', + value: 'test-secret-value', + }, + ], + }); + + const newRobotAccessToken = randomUUID(); + ctx.updateJobInformation( + { + secrets: { + robotAccessToken: newRobotAccessToken, + environmentSecrets: [ + { + name: 'TEST_SECRET', + value: 'new-test-secret-value', + }, + { + name: 'TEST_SECRET_2', + value: 'test-secret-value-2', + }, + ], + }, + } as Job, + {} as Metadata + ); + + expect(ctx.job.secrets).toEqual({ + robotAccessToken: newRobotAccessToken, + environmentSecrets: [ + { name: 'TEST_SECRET', value: 'test-secret-value' }, + { name: 'TEST_SECRET', value: 'new-test-secret-value' }, + { name: 'TEST_SECRET_2', value: 'test-secret-value-2' }, + ], + }); + }); + + it('should not lose workflowInterpolationContext', async () => { + const robotAccessToken = randomUUID(); + await vol.promises.mkdir('/workingdir/eas-environment-secrets/', { recursive: true }); + + const ctx = new BuildContext( + { + triggeredBy: BuildTrigger.GIT_BASED_INTEGRATION, + secrets: { + robotAccessToken, + environmentSecrets: [ + { + name: 'TEST_SECRET', + value: 'test-secret-value', + }, + ], + }, + workflowInterpolationContext: { + foo: 'bar', + } as any, + } as Job, + { + env: { + __API_SERVER_URL: 'http://api.expo.test', + }, + workingdir: '/workingdir', + logger: createMockLogger(), + logBuffer: { getLogs: () => [], getPhaseLogs: () => [] }, + uploadArtifact: jest.fn(), + } + ); + + ctx.updateJobInformation({} as Job, {} as Metadata); + + expect(ctx.job.workflowInterpolationContext).toEqual({ + foo: 'bar', + }); + }); +}); diff --git a/packages/build-tools/src/__tests__/customBuildContext.test.ts b/packages/build-tools/src/__tests__/customBuildContext.test.ts new file mode 100644 index 0000000000..f94c001f8a --- /dev/null +++ b/packages/build-tools/src/__tests__/customBuildContext.test.ts @@ -0,0 +1,38 @@ +import { BuildTrigger, Ios, Metadata } from '@expo/eas-build-job'; + +import { BuildContext } from '../context'; +import { CustomBuildContext } from '../customBuildContext'; + +import { createTestIosJob } from './utils/job'; +import { createMockLogger } from './utils/logger'; + +describe(CustomBuildContext, () => { + it('should not lose workflowInterpolationContext', () => { + const contextUploadArtifact = jest.fn(); + const ctx = new BuildContext( + createTestIosJob({ + triggeredBy: BuildTrigger.GIT_BASED_INTEGRATION, + workflowInterpolationContext: { + foo: 'bar', + } as unknown as Ios.Job['workflowInterpolationContext'], + }), + { + env: { + __API_SERVER_URL: 'http://api.expo.test', + }, + logBuffer: { getLogs: () => [], getPhaseLogs: () => [] }, + logger: createMockLogger(), + uploadArtifact: contextUploadArtifact, + workingdir: '', + } + ); + const customContext = new CustomBuildContext(ctx); + expect(customContext.job.workflowInterpolationContext).toStrictEqual({ + foo: 'bar', + }); + customContext.updateJobInformation({} as Ios.Job, {} as Metadata); + expect(customContext.job.workflowInterpolationContext).toStrictEqual({ + foo: 'bar', + }); + }); +}); diff --git a/packages/build-tools/src/__tests__/utils/context.ts b/packages/build-tools/src/__tests__/utils/context.ts new file mode 100644 index 0000000000..ecfd0b0e79 --- /dev/null +++ b/packages/build-tools/src/__tests__/utils/context.ts @@ -0,0 +1,101 @@ +import path from 'path'; +import os from 'os'; + +import { bunyan } from '@expo/logger'; +import { v4 as uuidv4 } from 'uuid'; +import { + BuildRuntimePlatform, + BuildStepContext, + BuildStepEnv, + BuildStepGlobalContext, + ExternalBuildContextProvider, +} from '@expo/steps'; + +import { createMockLogger } from './logger'; + +export class MockContextProvider implements ExternalBuildContextProvider { + private _env: BuildStepEnv = {}; + + constructor( + public readonly logger: bunyan, + public readonly runtimePlatform: BuildRuntimePlatform, + public readonly projectSourceDirectory: string, + public readonly projectTargetDirectory: string, + public readonly defaultWorkingDirectory: string, + public readonly buildLogsDirectory: string, + public readonly staticContextContent: Record = {} + ) {} + public get env(): BuildStepEnv { + return this._env; + } + public staticContext(): any { + return { ...this.staticContextContent }; + } + public updateEnv(env: BuildStepEnv): void { + this._env = env; + } +} + +interface BuildContextParams { + buildId?: string; + logger?: bunyan; + skipCleanup?: boolean; + runtimePlatform?: BuildRuntimePlatform; + projectSourceDirectory?: string; + projectTargetDirectory?: string; + relativeWorkingDirectory?: string; + staticContextContent?: Record; +} + +export function createStepContextMock({ + buildId, + logger, + skipCleanup, + runtimePlatform, + projectSourceDirectory, + projectTargetDirectory, + relativeWorkingDirectory, + staticContextContent, +}: BuildContextParams = {}): BuildStepContext { + const globalCtx = createGlobalContextMock({ + buildId, + logger, + skipCleanup, + runtimePlatform, + projectSourceDirectory, + projectTargetDirectory, + relativeWorkingDirectory, + staticContextContent, + }); + return new BuildStepContext(globalCtx, { + logger: logger ?? createMockLogger(), + relativeWorkingDirectory, + }); +} + +export function createGlobalContextMock({ + logger, + skipCleanup, + runtimePlatform, + projectSourceDirectory, + projectTargetDirectory, + relativeWorkingDirectory, + staticContextContent, +}: BuildContextParams = {}): BuildStepGlobalContext { + const resolvedProjectTargetDirectory = + projectTargetDirectory ?? path.join(os.tmpdir(), 'eas-build', uuidv4()); + return new BuildStepGlobalContext( + new MockContextProvider( + logger ?? createMockLogger(), + runtimePlatform ?? BuildRuntimePlatform.LINUX, + projectSourceDirectory ?? '/non/existent/dir', + resolvedProjectTargetDirectory, + relativeWorkingDirectory + ? path.resolve(resolvedProjectTargetDirectory, relativeWorkingDirectory) + : resolvedProjectTargetDirectory, + '/non/existent/dir', + staticContextContent ?? {} + ), + skipCleanup ?? false + ); +} diff --git a/packages/build-tools/src/__tests__/utils/job.ts b/packages/build-tools/src/__tests__/utils/job.ts new file mode 100644 index 0000000000..b4047b4101 --- /dev/null +++ b/packages/build-tools/src/__tests__/utils/job.ts @@ -0,0 +1,95 @@ +import { randomUUID } from 'crypto'; + +import { + Android, + ArchiveSourceType, + BuildMode, + BuildTrigger, + Ios, + Platform, + Workflow, +} from '@expo/eas-build-job'; + +const androidCredentials: Android.BuildSecrets['buildCredentials'] = { + keystore: { + dataBase64: 'MjEzNwo=', + keystorePassword: 'pass1', + keyAlias: 'alias', + keyPassword: 'pass2', + }, +}; + +const iosCredentials: Ios.BuildCredentials = { + testapp: { + provisioningProfileBase64: '', + distributionCertificate: { + dataBase64: '', + password: '', + }, + }, +}; + +export function createTestAndroidJob({ + buildCredentials = androidCredentials, +}: { + buildCredentials?: Android.BuildSecrets['buildCredentials']; +} = {}): Android.Job { + return { + mode: BuildMode.BUILD, + platform: Platform.ANDROID, + triggeredBy: BuildTrigger.EAS_CLI, + type: Workflow.GENERIC, + projectArchive: { + type: ArchiveSourceType.URL, + url: 'https://turtle-v2-test-fixtures.s3.us-east-2.amazonaws.com/project.tar.gz', + }, + projectRootDirectory: '.', + applicationArchivePath: './android/app/build/outputs/apk/release/*.apk', + cache: { + clear: false, + disabled: false, + paths: [], + }, + secrets: { + buildCredentials, + }, + appId: randomUUID(), + initiatingUserId: randomUUID(), + }; +} + +export function createTestIosJob({ + buildCredentials = iosCredentials, + triggeredBy = BuildTrigger.EAS_CLI, + workflowInterpolationContext, +}: { + buildCredentials?: Ios.BuildCredentials; + triggeredBy?: Ios.Job['triggeredBy']; + workflowInterpolationContext?: Ios.Job['workflowInterpolationContext']; +} = {}): Ios.Job { + return { + mode: BuildMode.BUILD, + platform: Platform.IOS, + triggeredBy, + type: Workflow.GENERIC, + projectArchive: { + type: ArchiveSourceType.URL, + url: 'https://turtle-v2-test-fixtures.s3.us-east-2.amazonaws.com/project.tar.gz', + }, + scheme: 'turtlebareproj', + buildConfiguration: 'Release', + applicationArchivePath: './ios/build/*.ipa', + projectRootDirectory: '.', + cache: { + clear: false, + disabled: false, + paths: [], + }, + secrets: { + buildCredentials, + }, + appId: randomUUID(), + initiatingUserId: randomUUID(), + workflowInterpolationContext, + }; +} diff --git a/packages/build-tools/src/__tests__/utils/logger.ts b/packages/build-tools/src/__tests__/utils/logger.ts new file mode 100644 index 0000000000..4a1238cb31 --- /dev/null +++ b/packages/build-tools/src/__tests__/utils/logger.ts @@ -0,0 +1,12 @@ +import { bunyan } from '@expo/logger'; + +export function createMockLogger({ logToConsole = false } = {}): bunyan { + const logger = { + info: jest.fn(logToConsole ? console.info : () => {}), + debug: jest.fn(logToConsole ? console.debug : () => {}), + error: jest.fn(logToConsole ? console.error : () => {}), + warn: jest.fn(logToConsole ? console.warn : () => {}), + child: jest.fn().mockImplementation(() => createMockLogger({ logToConsole })), + } as unknown as bunyan; + return logger; +} diff --git a/packages/build-tools/src/android/__tests__/expoUpdates.test.ts b/packages/build-tools/src/android/__tests__/expoUpdates.test.ts new file mode 100644 index 0000000000..8177a4e459 --- /dev/null +++ b/packages/build-tools/src/android/__tests__/expoUpdates.test.ts @@ -0,0 +1,148 @@ +import path from 'path'; + +import { vol } from 'memfs'; +import { AndroidConfig } from '@expo/config-plugins'; + +import { + AndroidMetadataName, + androidSetChannelNativelyAsync, + androidGetNativelyDefinedChannelAsync, + androidGetNativelyDefinedRuntimeVersionAsync, + androidSetRuntimeVersionNativelyAsync, +} from '../expoUpdates'; + +jest.mock('fs'); +const originalFs = jest.requireActual('fs'); + +const channel = 'easupdatechannel'; +const manifestPath = '/app/android/app/src/main/AndroidManifest.xml'; + +afterEach(() => { + vol.reset(); +}); + +describe(androidSetChannelNativelyAsync, () => { + it('sets the channel', async () => { + vol.fromJSON( + { + 'android/app/src/main/AndroidManifest.xml': originalFs.readFileSync( + path.join(__dirname, 'fixtures/NoMetadataAndroidManifest.xml'), + 'utf-8' + ), + }, + '/app' + ); + const ctx = { + getReactNativeProjectDirectory: () => '/app', + job: { updates: { channel } }, + logger: { info: () => {} }, + }; + + await androidSetChannelNativelyAsync(ctx as any); + + const newAndroidManifest = await AndroidConfig.Manifest.readAndroidManifestAsync(manifestPath); + const newValue = AndroidConfig.Manifest.getMainApplicationMetaDataValue( + newAndroidManifest, + AndroidMetadataName.UPDATES_CONFIGURATION_REQUEST_HEADERS_KEY + ); + expect(newValue).toBeDefined(); + expect(JSON.parse(newValue!)).toEqual({ 'expo-channel-name': channel }); + }); +}); + +describe(androidGetNativelyDefinedChannelAsync, () => { + it('gets the channel', async () => { + vol.fromJSON( + { + 'android/app/src/main/AndroidManifest.xml': originalFs.readFileSync( + path.join(__dirname, 'fixtures/AndroidManifestWithChannel.xml'), + 'utf-8' + ), + }, + '/app' + ); + const ctx = { + getReactNativeProjectDirectory: () => '/app', + logger: { info: () => {} }, + }; + + await expect(androidGetNativelyDefinedChannelAsync(ctx as any)).resolves.toBe('staging-123'); + }); +}); + +describe(androidGetNativelyDefinedRuntimeVersionAsync, () => { + it('gets the native runtime version', async () => { + vol.fromJSON( + { + 'android/app/src/main/AndroidManifest.xml': originalFs.readFileSync( + path.join(__dirname, 'fixtures/AndroidManifestWithRuntimeVersion.xml'), + 'utf-8' + ), + }, + '/app' + ); + const ctx = { + getReactNativeProjectDirectory: () => '/app', + job: {}, + logger: { info: () => {} }, + }; + + const nativelyDefinedRuntimeVersion = await androidGetNativelyDefinedRuntimeVersionAsync( + ctx as any + ); + expect(nativelyDefinedRuntimeVersion).toBe('exampleruntimeversion'); + }); +}); + +describe(androidSetRuntimeVersionNativelyAsync, () => { + it('sets the runtime version when nothing is set natively', async () => { + vol.fromJSON( + { + 'android/app/src/main/AndroidManifest.xml': originalFs.readFileSync( + path.join(__dirname, 'fixtures/NoMetadataAndroidManifest.xml'), + 'utf-8' + ), + }, + '/app' + ); + const ctx = { + getReactNativeProjectDirectory: () => '/app', + job: {}, + logger: { info: () => {} }, + }; + + await androidSetRuntimeVersionNativelyAsync(ctx as any, '1.2.3'); + + const newAndroidManifest = await AndroidConfig.Manifest.readAndroidManifestAsync(manifestPath); + const newValue = AndroidConfig.Manifest.getMainApplicationMetaDataValue( + newAndroidManifest, + AndroidMetadataName.RUNTIME_VERSION + ); + expect(newValue).toBe('1.2.3'); + }); + it('updates the runtime version when value is already set natively', async () => { + vol.fromJSON( + { + 'android/app/src/main/AndroidManifest.xml': originalFs.readFileSync( + path.join(__dirname, 'fixtures/AndroidManifestWithRuntimeVersion.xml'), + 'utf-8' + ), + }, + '/app' + ); + const ctx = { + getReactNativeProjectDirectory: () => '/app', + job: {}, + logger: { info: () => {} }, + }; + + await androidSetRuntimeVersionNativelyAsync(ctx as any, '1.2.3'); + + const newAndroidManifest = await AndroidConfig.Manifest.readAndroidManifestAsync(manifestPath); + const newValue = AndroidConfig.Manifest.getMainApplicationMetaDataValue( + newAndroidManifest, + AndroidMetadataName.RUNTIME_VERSION + ); + expect(newValue).toBe('1.2.3'); + }); +}); diff --git a/packages/build-tools/src/android/__tests__/fixtures/AndroidManifestWithChannel.xml b/packages/build-tools/src/android/__tests__/fixtures/AndroidManifestWithChannel.xml new file mode 100644 index 0000000000..70fe8e5707 --- /dev/null +++ b/packages/build-tools/src/android/__tests__/fixtures/AndroidManifestWithChannel.xml @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/packages/build-tools/src/android/__tests__/fixtures/AndroidManifestWithClassicReleaseChannel.xml b/packages/build-tools/src/android/__tests__/fixtures/AndroidManifestWithClassicReleaseChannel.xml new file mode 100644 index 0000000000..2b9936d374 --- /dev/null +++ b/packages/build-tools/src/android/__tests__/fixtures/AndroidManifestWithClassicReleaseChannel.xml @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/packages/build-tools/src/android/__tests__/fixtures/AndroidManifestWithRuntimeVersion.xml b/packages/build-tools/src/android/__tests__/fixtures/AndroidManifestWithRuntimeVersion.xml new file mode 100644 index 0000000000..faf629bafa --- /dev/null +++ b/packages/build-tools/src/android/__tests__/fixtures/AndroidManifestWithRuntimeVersion.xml @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/packages/build-tools/src/android/__tests__/fixtures/NoMetadataAndroidManifest.xml b/packages/build-tools/src/android/__tests__/fixtures/NoMetadataAndroidManifest.xml new file mode 100644 index 0000000000..b680312954 --- /dev/null +++ b/packages/build-tools/src/android/__tests__/fixtures/NoMetadataAndroidManifest.xml @@ -0,0 +1,27 @@ + + + + + + + + + + + + + + + diff --git a/packages/build-tools/src/android/credentials.ts b/packages/build-tools/src/android/credentials.ts new file mode 100644 index 0000000000..23aa799eba --- /dev/null +++ b/packages/build-tools/src/android/credentials.ts @@ -0,0 +1,38 @@ +import path from 'path'; + +import { Android } from '@expo/eas-build-job'; +import fs from 'fs-extra'; +import nullthrows from 'nullthrows'; +import { v4 as uuidv4 } from 'uuid'; + +import { BuildContext } from '../context'; + +async function restoreCredentials(ctx: BuildContext): Promise { + const { buildCredentials } = nullthrows( + ctx.job.secrets, + 'Secrets must be defined for non-custom builds' + ); + if (!buildCredentials) { + // TODO: sentry (should be detected earlier) + throw new Error('secrets are missing in the job object'); + } + ctx.logger.info("Writing secrets to the project's directory"); + const keystorePath = path.join(ctx.buildDirectory, `keystore-${uuidv4()}`); + await fs.writeFile(keystorePath, new Uint8Array(Buffer.from(buildCredentials.keystore.dataBase64, 'base64'))); + const credentialsJson = { + android: { + keystore: { + keystorePath, + keystorePassword: buildCredentials.keystore.keystorePassword, + keyAlias: buildCredentials.keystore.keyAlias, + keyPassword: buildCredentials.keystore.keyPassword, + }, + }, + }; + await fs.writeFile( + path.join(ctx.buildDirectory, 'credentials.json'), + JSON.stringify(credentialsJson) + ); +} + +export { restoreCredentials }; diff --git a/packages/build-tools/src/android/expoUpdates.ts b/packages/build-tools/src/android/expoUpdates.ts new file mode 100644 index 0000000000..0fb40cb3f3 --- /dev/null +++ b/packages/build-tools/src/android/expoUpdates.ts @@ -0,0 +1,107 @@ +import assert from 'assert'; + +import fs from 'fs-extra'; +import { AndroidConfig } from '@expo/config-plugins'; +import { BuildJob, Job } from '@expo/eas-build-job'; + +import { BuildContext } from '../context'; + +export enum AndroidMetadataName { + UPDATES_CONFIGURATION_REQUEST_HEADERS_KEY = 'expo.modules.updates.UPDATES_CONFIGURATION_REQUEST_HEADERS_KEY', + RUNTIME_VERSION = 'expo.modules.updates.EXPO_RUNTIME_VERSION', +} + +export async function androidSetRuntimeVersionNativelyAsync( + ctx: BuildContext, + runtimeVersion: string +): Promise { + const manifestPath = await AndroidConfig.Paths.getAndroidManifestAsync( + ctx.getReactNativeProjectDirectory() + ); + + if (!(await fs.pathExists(manifestPath))) { + throw new Error(`Couldn't find Android manifest at ${manifestPath}`); + } + + const androidManifest = await AndroidConfig.Manifest.readAndroidManifestAsync(manifestPath); + const mainApp = AndroidConfig.Manifest.getMainApplicationOrThrow(androidManifest); + AndroidConfig.Manifest.addMetaDataItemToMainApplication( + mainApp, + AndroidMetadataName.RUNTIME_VERSION, + runtimeVersion, + 'value' + ); + await AndroidConfig.Manifest.writeAndroidManifestAsync(manifestPath, androidManifest); +} + +export async function androidSetChannelNativelyAsync(ctx: BuildContext): Promise { + assert(ctx.job.updates?.channel, 'updates.channel must be defined'); + + const manifestPath = await AndroidConfig.Paths.getAndroidManifestAsync( + ctx.getReactNativeProjectDirectory() + ); + + if (!(await fs.pathExists(manifestPath))) { + throw new Error(`Couldn't find Android manifest at ${manifestPath}`); + } + + const androidManifest = await AndroidConfig.Manifest.readAndroidManifestAsync(manifestPath); + const mainApp = AndroidConfig.Manifest.getMainApplicationOrThrow(androidManifest); + const stringifiedUpdatesRequestHeaders = AndroidConfig.Manifest.getMainApplicationMetaDataValue( + androidManifest, + AndroidMetadataName.UPDATES_CONFIGURATION_REQUEST_HEADERS_KEY + ); + AndroidConfig.Manifest.addMetaDataItemToMainApplication( + mainApp, + AndroidMetadataName.UPDATES_CONFIGURATION_REQUEST_HEADERS_KEY, + JSON.stringify({ + ...JSON.parse(stringifiedUpdatesRequestHeaders ?? '{}'), + 'expo-channel-name': ctx.job.updates.channel, + }), + 'value' + ); + await AndroidConfig.Manifest.writeAndroidManifestAsync(manifestPath, androidManifest); +} + +export async function androidGetNativelyDefinedChannelAsync( + ctx: BuildContext +): Promise { + const manifestPath = await AndroidConfig.Paths.getAndroidManifestAsync( + ctx.getReactNativeProjectDirectory() + ); + + if (!(await fs.pathExists(manifestPath))) { + return null; + } + + const androidManifest = await AndroidConfig.Manifest.readAndroidManifestAsync(manifestPath); + const stringifiedUpdatesRequestHeaders = AndroidConfig.Manifest.getMainApplicationMetaDataValue( + androidManifest, + AndroidMetadataName.UPDATES_CONFIGURATION_REQUEST_HEADERS_KEY + ); + try { + const updatesRequestHeaders = JSON.parse(stringifiedUpdatesRequestHeaders ?? '{}'); + return updatesRequestHeaders['expo-channel-name'] ?? null; + } catch (err: any) { + throw new Error( + `Failed to parse ${AndroidMetadataName.UPDATES_CONFIGURATION_REQUEST_HEADERS_KEY} from AndroidManifest.xml: ${err.message}` + ); + } +} + +export async function androidGetNativelyDefinedRuntimeVersionAsync( + ctx: BuildContext +): Promise { + const manifestPath = await AndroidConfig.Paths.getAndroidManifestAsync( + ctx.getReactNativeProjectDirectory() + ); + if (!(await fs.pathExists(manifestPath))) { + return null; + } + + const androidManifest = await AndroidConfig.Manifest.readAndroidManifestAsync(manifestPath); + return AndroidConfig.Manifest.getMainApplicationMetaDataValue( + androidManifest, + AndroidMetadataName.RUNTIME_VERSION + ); +} diff --git a/packages/build-tools/src/android/gradle.ts b/packages/build-tools/src/android/gradle.ts new file mode 100644 index 0000000000..12fef5554b --- /dev/null +++ b/packages/build-tools/src/android/gradle.ts @@ -0,0 +1,119 @@ +import path from 'path'; +import assert from 'assert'; + +import spawn, { SpawnPromise, SpawnResult } from '@expo/turtle-spawn'; +import { Android, Env, Job, Platform } from '@expo/eas-build-job'; +import fs from 'fs-extra'; +import { bunyan } from '@expo/logger'; + +import { BuildContext } from '../context'; +import { getParentAndDescendantProcessPidsAsync } from '../utils/processes'; + +export async function ensureLFLineEndingsInGradlewScript( + ctx: BuildContext +): Promise { + const gradlewPath = path.join(ctx.getReactNativeProjectDirectory(), 'android', 'gradlew'); + const gradlewContent = await fs.readFile(gradlewPath, 'utf8'); + if (gradlewContent.includes('\r')) { + ctx.logger.info('Replacing CRLF line endings with LF in gradlew script'); + await fs.writeFile(gradlewPath, gradlewContent.replace(/\r\n/g, '\n'), 'utf8'); + } +} + +export async function runGradleCommand( + ctx: BuildContext, + { + logger, + gradleCommand, + androidDir, + extraEnv, + }: { logger: bunyan; gradleCommand: string; androidDir: string; extraEnv?: Env } +): Promise { + logger.info(`Running 'gradlew ${gradleCommand}' in ${androidDir}`); + await fs.chmod(path.join(androidDir, 'gradlew'), 0o755); + const verboseFlag = ctx.env['EAS_VERBOSE'] === '1' ? '--info' : ''; + + const spawnPromise = spawn('bash', ['-c', `./gradlew ${gradleCommand} ${verboseFlag}`], { + cwd: androidDir, + logger, + lineTransformer: (line?: string) => { + if (!line || /^\.+$/.exec(line)) { + return null; + } else { + return line; + } + }, + env: { ...ctx.env, ...extraEnv, ...resolveVersionOverridesEnvs(ctx) }, + }); + if (ctx.env.EAS_BUILD_RUNNER === 'eas-build' && process.platform === 'linux') { + adjustOOMScore(spawnPromise, logger); + } + + await spawnPromise; +} + +/** + * OOM Killer sometimes kills worker server while build is exceeding memory limits. + * `oom_score_adj` is a value between -1000 and 1000 and it defaults to 0. + * It defines which process is more likely to get killed (higher value more likely). + * + * This function sets oom_score_adj for Gradle process and all its child processes. + */ +function adjustOOMScore(spawnPromise: SpawnPromise, logger: bunyan): void { + setTimeout( + async () => { + try { + assert(spawnPromise.child.pid); + const pids = await getParentAndDescendantProcessPidsAsync(spawnPromise.child.pid); + await Promise.all( + pids.map(async (pid: number) => { + // Value 800 is just a guess here. It's probably higher than most other + // process. I didn't want to set it any higher, because I'm not sure if OOM Killer + // can start killing processes when there is still enough memory left. + const oomScoreOverride = 800; + await fs.writeFile(`/proc/${pid}/oom_score_adj`, `${oomScoreOverride}\n`); + }) + ); + } catch (err: any) { + logger.debug({ err, stderr: err?.stderr }, 'Failed to override oom_score_adj'); + } + }, + // Wait 20 seconds to make sure all child processes are started + 20000 + ); +} + +// Version envs should be set at the beginning of the build, but when building +// from github those values are resolved later. +function resolveVersionOverridesEnvs(ctx: BuildContext): Env { + const extraEnvs: Env = {}; + if ( + ctx.job.platform === Platform.ANDROID && + ctx.job.version?.versionCode && + !ctx.env.EAS_BUILD_ANDROID_VERSION_CODE + ) { + extraEnvs.EAS_BUILD_ANDROID_VERSION_CODE = ctx.job.version.versionCode; + } + if ( + ctx.job.platform === Platform.ANDROID && + ctx.job.version?.versionName && + !ctx.env.EAS_BUILD_ANDROID_VERSION_NAME + ) { + extraEnvs.EAS_BUILD_ANDROID_VERSION_NAME = ctx.job.version.versionName; + } + return extraEnvs; +} + +export function resolveGradleCommand(job: Android.Job): string { + if (job.gradleCommand) { + return job.gradleCommand; + } else if (job.developmentClient) { + return ':app:assembleDebug'; + } else if (!job.buildType) { + return ':app:bundleRelease'; + } else if (job.buildType === Android.BuildType.APK) { + return ':app:assembleRelease'; + } else { + return ':app:bundleRelease'; + } +} diff --git a/packages/build-tools/src/android/gradleConfig.ts b/packages/build-tools/src/android/gradleConfig.ts new file mode 100644 index 0000000000..88f2e212fd --- /dev/null +++ b/packages/build-tools/src/android/gradleConfig.ts @@ -0,0 +1,58 @@ +import path from 'path'; + +import { AndroidConfig } from '@expo/config-plugins'; +import { Android } from '@expo/eas-build-job'; +import fs from 'fs-extra'; + +import { BuildContext } from '../context'; +import { EasBuildGradle } from '../templates/EasBuildGradle'; +const APPLY_EAS_BUILD_GRADLE_LINE = 'apply from: "./eas-build.gradle"'; + +export async function configureBuildGradle(ctx: BuildContext): Promise { + ctx.logger.info('Injecting signing config into build.gradle'); + if (await fs.pathExists(getEasBuildGradlePath(ctx.getReactNativeProjectDirectory()))) { + ctx.markBuildPhaseHasWarnings(); + ctx.logger.warn('eas-build.gradle script is deprecated, please remove it from your project.'); + } + await deleteEasBuildGradle(ctx.getReactNativeProjectDirectory()); + await createEasBuildGradle(ctx.getReactNativeProjectDirectory()); + await addApplyToBuildGradle(ctx.getReactNativeProjectDirectory()); +} + +async function deleteEasBuildGradle(projectRoot: string): Promise { + const easBuildGradlePath = getEasBuildGradlePath(projectRoot); + await fs.remove(easBuildGradlePath); +} + +function getEasBuildGradlePath(projectRoot: string): string { + return path.join(projectRoot, 'android/app/eas-build.gradle'); +} + +async function createEasBuildGradle(projectRoot: string): Promise { + const easBuildGradlePath = getEasBuildGradlePath(projectRoot); + await fs.writeFile(easBuildGradlePath, EasBuildGradle); +} + +async function addApplyToBuildGradle(projectRoot: string): Promise { + const buildGradlePath = AndroidConfig.Paths.getAppBuildGradleFilePath(projectRoot); + const buildGradleContents = await fs.readFile(path.join(buildGradlePath), 'utf8'); + + if (hasLine(buildGradleContents, APPLY_EAS_BUILD_GRADLE_LINE)) { + return; + } + + await fs.writeFile( + buildGradlePath, + `${buildGradleContents.trim()}\n${APPLY_EAS_BUILD_GRADLE_LINE}\n` + ); +} + +function hasLine(haystack: string, needle: string): boolean { + return ( + haystack + .replace(/\r\n/g, '\n') + .split('\n') + // Check for both single and double quotes + .some((line) => line === needle || line === needle.replace(/"/g, "'")) + ); +} diff --git a/packages/build-tools/src/buildErrors/__tests__/detectError.test.ts b/packages/build-tools/src/buildErrors/__tests__/detectError.test.ts new file mode 100644 index 0000000000..f569fb28a8 --- /dev/null +++ b/packages/build-tools/src/buildErrors/__tests__/detectError.test.ts @@ -0,0 +1,294 @@ +import path from 'path'; + +import { BuildMode, BuildPhase, errors, Job, Platform } from '@expo/eas-build-job'; +import { vol } from 'memfs'; + +import { resolveBuildPhaseErrorAsync } from '../detectError'; + +jest.mock('fs'); +const originalFs = jest.requireActual('fs'); + +afterEach(() => { + vol.reset(); +}); + +describe(resolveBuildPhaseErrorAsync, () => { + it('detects log for corrupted npm package', async () => { + const fakeError = new Error(); + const err = await resolveBuildPhaseErrorAsync( + fakeError, + [ + '[stderr] WARN tarball tarball data for @typescript-eslint/typescript-estree@5.26.0 (sha512-cozo/GbwixVR0sgfHItz3t1yXu521yn71Wj6PlYCFA3WPhy51CUPkifFKfBis91bDclGmAY45hhaAXVjdn4new==) seems to be corrupted. Trying again.', + ], + { + job: { platform: Platform.ANDROID } as Job, + phase: BuildPhase.INSTALL_DEPENDENCIES, + env: {}, + }, + '/fake/path' + ); + expect(err.errorCode).toBe('NPM_CORRUPTED_PACKAGE'); + expect(err.userFacingErrorCode).toBe(errors.ErrorCode.UNKNOWN_ERROR); + }); + + it('detects log for invalid bundler and reports it to user', async () => { + const fakeError = new Error(); + const err = await resolveBuildPhaseErrorAsync( + fakeError, + [ + "/System/Library/Frameworks/Ruby.framework/Versions/2.6/usr/lib/ruby/2.6.0/rubygems/dependency.rb:313:in `to_specs': Could not find 'bundler' (2.2.3) required by your /Users/expo/project/build/ios/Gemfile.lock. (Gem::MissingSpecVersionError)", + ], + { + job: { platform: Platform.IOS } as Job, + phase: BuildPhase.RUN_FASTLANE, + env: {}, + }, + '/fake/path' + ); + expect(err.errorCode).toBe('EAS_BUILD_UNSUPPORTED_BUNDLER_VERSION_ERROR'); + expect(err.userFacingErrorCode).toBe('EAS_BUILD_UNSUPPORTED_BUNDLER_VERSION_ERROR'); + }); + + it('does not detect errors if they show up in different build phase', async () => { + const fakeError = new Error(); + const err = await resolveBuildPhaseErrorAsync( + fakeError, + [ + "/System/Library/Frameworks/Ruby.framework/Versions/2.6/usr/lib/ruby/2.6.0/rubygems/dependency.rb:313:in `to_specs': Could not find 'bundler' (2.2.3) required by your /Users/expo/project/build/ios/Gemfile.lock. (Gem::MissingSpecVersionError)", + ], + { + job: { platform: Platform.IOS } as Job, + phase: BuildPhase.INSTALL_DEPENDENCIES, // it should be in RUN_FASTLANE + env: {}, + }, + '/fake/path' + ); + expect(err.errorCode).toBe(errors.ErrorCode.UNKNOWN_ERROR); + expect(err.userFacingErrorCode).toBe(errors.ErrorCode.UNKNOWN_ERROR); + }); + + it('detects npm cache error if cache is enabled', async () => { + const mockEnv = { + EAS_BUILD_NPM_CACHE_URL: 'https://dominik.sokal.pl/npm/cache', + }; + + const fakeError = new Error(); + const err = await resolveBuildPhaseErrorAsync( + fakeError, + [`Blah blah Error ... ${mockEnv.EAS_BUILD_NPM_CACHE_URL}`], + { + job: { platform: Platform.ANDROID } as Job, + phase: BuildPhase.INSTALL_DEPENDENCIES, + env: mockEnv, + }, + '/fake/path' + ); + expect(err.errorCode).toBe('NPM_CACHE_ERROR'); + expect(err.userFacingErrorCode).toBe(errors.ErrorCode.UNKNOWN_ERROR); + }); + + it('does not detect npm cache error if cache is disabled', async () => { + const mockEnv = { + EAS_BUILD_NPM_CACHE_URL: 'https://dominik.sokal.pl/npm/cache', + }; + + const fakeError = new Error(); + const err = await resolveBuildPhaseErrorAsync( + fakeError, + [`Blah blah Error ... ${mockEnv.EAS_BUILD_NPM_CACHE_URL}`], + { + job: { platform: Platform.ANDROID } as Job, + phase: BuildPhase.INSTALL_DEPENDENCIES, + env: {}, + }, + '/fake/path' + ); + expect(err.errorCode).toBe(errors.ErrorCode.UNKNOWN_ERROR); + expect(err.userFacingErrorCode).toBe(errors.ErrorCode.UNKNOWN_ERROR); + }); + + it('detects xcode line error', async () => { + vol.fromJSON({ + '/path/to/xcodelogs.log': originalFs.readFileSync( + path.resolve('./src/buildErrors/__tests__/fixtures/xcode.log'), + 'utf-8' + ), + }); + + const fakeError = new Error(); + const err = await resolveBuildPhaseErrorAsync( + fakeError, + [''], + { + job: { platform: Platform.IOS } as Job, + phase: BuildPhase.RUN_FASTLANE, + env: {}, + }, + '/path/to/' + ); + expect(err.errorCode).toBe('XCODE_RESOURCE_BUNDLE_CODE_SIGNING_ERROR'); + expect(err.userFacingErrorCode).toBe('XCODE_RESOURCE_BUNDLE_CODE_SIGNING_ERROR'); + }); + + it('detects minimum deployment target error correctly', async () => { + const fakeError = new Error(); + const err = await resolveBuildPhaseErrorAsync( + fakeError, + [ + 'CocoaPods could not find compatible versions for pod "react-native-google-maps":16 In Podfile:17 react-native-google-maps (from `/Users/expo/workingdir/build/node_modules/react-native-maps`)18Specs satisfying the `react-native-google-maps (from `/Users/expo/workingdir/build/node_modules/react-native-maps`)` dependency were found, but they required a higher minimum deployment target.19Error: Compatible versions of some pods could not be resolved.', + ], + { + job: { platform: Platform.IOS } as Job, + phase: BuildPhase.INSTALL_PODS, + env: {}, + }, + '/fake/path' + ); + expect(err.errorCode).toBe('EAS_BUILD_HIGHER_MINIMUM_DEPLOYMENT_TARGET_ERROR'); + expect(err.userFacingErrorCode).toBe('EAS_BUILD_HIGHER_MINIMUM_DEPLOYMENT_TARGET_ERROR'); + }); + + it('detects provisioning profile mismatch error correctly', async () => { + const fakeError = new Error(); + const err = await resolveBuildPhaseErrorAsync( + fakeError, + [ + `No provisioning profile for application: '_floatsignTemp/Payload/EcoBatteryPREVIEW.app' with bundle identifier 'com.ecobattery.ecobattery-preview'`, + ], + { + job: { platform: Platform.IOS, mode: BuildMode.RESIGN } as Job, + phase: BuildPhase.RUN_FASTLANE, + env: {}, + }, + '/fake/path' + ); + expect(err.errorCode).toBe('EAS_BUILD_RESIGN_PROVISIONING_PROFILE_MISMATCH_ERROR'); + expect(err.userFacingErrorCode).toBe('EAS_BUILD_RESIGN_PROVISIONING_PROFILE_MISMATCH_ERROR'); + }); + + it('detects generic "Run Fastlane" error for resign correctly', async () => { + const fakeError = new Error(); + const err = await resolveBuildPhaseErrorAsync( + fakeError, + [`other error`], + { + job: { platform: Platform.IOS, mode: BuildMode.RESIGN } as Job, + phase: BuildPhase.RUN_FASTLANE, + env: {}, + }, + '/fake/path' + ); + expect(err.errorCode).toBe('EAS_BUILD_UNKNOWN_FASTLANE_RESIGN_ERROR'); + expect(err.userFacingErrorCode).toBe('EAS_BUILD_UNKNOWN_FASTLANE_RESIGN_ERROR'); + }); + + it('detects build error in "Run Fastlane" phase correctly', async () => { + const fakeError = new Error(); + const err = await resolveBuildPhaseErrorAsync( + fakeError, + [`some build error`], + { + job: { platform: Platform.IOS, mode: BuildMode.BUILD } as Job, + phase: BuildPhase.RUN_FASTLANE, + env: {}, + }, + '/fake/path' + ); + expect(err.errorCode).toBe('EAS_BUILD_UNKNOWN_FASTLANE_ERROR'); + expect(err.userFacingErrorCode).toBe('EAS_BUILD_UNKNOWN_FASTLANE_ERROR'); + }); + + it('detects provisioning profile mismatch error correctly', async () => { + const xcodeLogs = `Intermediates.noindex/Pods.build/Release-iphonesimulator/Yoga.build/Objects-normal/arm64/82b82416624d2658e5098eb0a28c15c5-common-args.resp +-target arm64-apple-ios15.0-simulator '-std=gnu++14' '-stdlib=libc++' -fmodules '-fmodules-cache-path=/Users/expo/workingdir/build/packages/apps/NFLNetwork/ios/build/ModuleCache.noindex' '-fmodule-name=yoga' -fpascal-strings -Os -fno-common '-DPOD_CONFIGURATION_RELEASE=1' '-DCOCOAPODS=1' -isysroot /Applications/Xcode.app/Contents/Developer/Platforms/iPhoneSimulator.platform/Developer/SDKs/iPhoneSimulator18.1.sdk -g -iquote /Users/expo/workingdir/build/packages/apps/NFLNetwork/ios/build/Build/Intermediates.noindex/Pods.build/Release-iphonesimulator/Yoga.build/Yoga-generated-files.hmap -I/Users/expo/workingdir/build/packages/apps/NFLNetwork/ios/build/Build/Intermediates.noindex/Pods.build/Release-iphonesimulator/Yoga.build/Yoga-own-target-headers.hmap -I/Users/expo/workingdir/build/packages/apps/NFLNetwork/ios/build/Build/Intermediates.noindex/Pods.build/Release-iphonesimulator/Yoga.build/Yoga-all-non-framework-target-headers.hmap -ivfsoverlay /Users/expo/workingdir/build/packages/apps/NFLNetwork/ios/build/Build/Intermediates.noindex/Pods.build/Release-iphonesimulator/Pods-8699adb1dd336b26511df848a716bd42-VFS-iphonesimulator/all-product-headers.yaml -iquote /Users/expo/workingdir/build/packages/apps/NFLNetwork/ios/build/Build/Intermediates.noindex/Pods.build/Release-iphonesimulator/Yoga.build/Yoga-project-headers.hmap -I/Users/expo/workingdir/build/packages/apps/NFLNetwork/ios/build/Build/Products/Release-iphonesimulator/Yoga/include -I/Users/expo/workingdir/build/packages/apps/NFLNetwork/ios/Pods/Headers/Private -I/Users/expo/workingdir/build/packages/apps/NFLNetwork/ios/Pods/Headers/Private/Yoga -I/Users/expo/workingdir/build/packages/apps/NFLNetwork/ios/Pods/Headers/Public -I/Users/expo/workingdir/build/packages/apps/NFLNetwork/ios/Pods/Headers/Public/Yoga -I/Users/expo/workingdir/build/packages/apps/NFLNetwork/ios/build/Build/Intermediates.noindex/Pods.build/Release-iphonesimulator/Yoga.build/DerivedSources-normal/arm64 -I/Users/expo/workingdir/build/packages/apps/NFLNetwork/ios/build/Build/Intermediates.noindex/Pods.build/Release-iphonesimulator/Yoga.build/DerivedSources/arm64 -I/Users/expo/workingdir/build/packages/apps/NFLNetwork/ios/build/Build/Intermediates.noindex/Pods.build/Release-iphonesimulator/Yoga.build/DerivedSources -F/Users/expo/workingdir/build/packages/apps/NFLNetwork/ios/build/Build/Products/Release-iphonesimulator/Yoga +MkDir /Users/expo/workingdir/build/packages/apps/NFLNetwork/ios/build/Build/Products/Release-iphonesimulator/expo-dev-menu/EXDevMenu.bundle (in target 'expo-dev-menu-EXDevMenu' from project 'Pods') + cd /Users/expo/workingdir/build/packages/apps/NFLNetwork/ios/Pods + /bin/mkdir -p /Users/expo/workingdir/build/packages/apps/NFLNetwork/ios/build/Build/Products/Release-iphonesimulator/expo-dev-menu/EXDevMenu.bundle + +MkDir /Users/expo/workingdir/build/packages/apps/NFLNetwork/ios/build/Build/Products/Release-iphonesimulator/expo-dev-launcher/EXDevLauncher.bundle (in target 'expo-dev-launcher-EXDevLauncher' from project 'Pods') + cd /Users/expo/workingdir/build/packages/apps/NFLNetwork/ios/Pods + /bin/mkdir -p /Users/expo/workingdir/build/packages/apps/NFLNetwork/ios/build/Build/Products/Release-iphonesimulator/expo-dev-launcher/EXDevLauncher.bundle + +ProcessXCFramework /Users/expo/workingdir/build/packages/video/ios/Vendor/dependency/ProgrammaticAccessLibrary.xcframework /Users/expo/workingdir/build/packages/apps/NFLNetwork/ios/build/Build/Products/Release-iphonesimulator/ProgrammaticAccessLibrary.framework ios simulator + cd /Users/expo/workingdir/build/packages/apps/NFLNetwork/ios + builtin-process-xcframework --xcframework /Users/expo/workingdir/build/packages/video/ios/Vendor/dependency/ProgrammaticAccessLibrary.xcframework --platform ios --environment simulator --target-path /Users/expo/workingdir/build/packages/apps/NFLNetwork/ios/build/Build/Products/Release-iphonesimulator +/Users/expo/workingdir/build/packages/video/ios/Vendor/dependency/ProgrammaticAccessLibrary.xcframework:1:1: error: The signature of “ProgrammaticAccessLibrary.xcframework” cannot be verified. + note: A sealed resource is missing or invalid + note: /Users/expo/workingdir/build/packages/video/ios/Vendor/dependency/ProgrammaticAccessLibrary.xcframework: a sealed resource is missing or invalid +file modified: /Users/expo/workingdir/build/packages/video/ios/Vendor/dependency/ProgrammaticAccessLibrary.xcframework/ios-arm64_x86_64-simulator/ProgrammaticAccessLibrary.framework/ProgrammaticAccessLibrary +file modified: /Users/expo/workingdir/build/packages/video/ios/Vendor/dependency/ProgrammaticAccessLibrary.xcframework/ios-arm64/ProgrammaticAccessLibrary.framework/ProgrammaticAccessLibrary +error: Some other error +log +log +log +error: The last one +log +note`; + + vol.fromJSON({ + '/path/to/xcode.log': xcodeLogs, + }); + + const fakeError = new Error(); + const err = await resolveBuildPhaseErrorAsync( + fakeError, + [`some logs`], + { + job: { platform: Platform.IOS, mode: BuildMode.BUILD } as Job, + phase: BuildPhase.RUN_FASTLANE, + env: {}, + }, + '/path/to' + ); + expect(err.errorCode).toBe('XCODE_BUILD_ERROR'); + expect(err.userFacingErrorCode).toBe('XCODE_BUILD_ERROR'); + expect(err.userFacingMessage) + .toBe(`The "Run fastlane" step failed because of an error in the Xcode build process. We automatically detected following errors in your Xcode build logs: +- The signature of “ProgrammaticAccessLibrary.xcframework” cannot be verified. +- Some other error +- The last one +Refer to "Xcode Logs" below for additional, more detailed logs.`); + }); + + it('detects MAVEN_CACHE_ERROR correctly', async () => { + const err = await resolveBuildPhaseErrorAsync( + new Error(), + [`https://szymon.pl/maven/cache`], + { + job: { platform: Platform.ANDROID, mode: BuildMode.BUILD } as Job, + phase: BuildPhase.RUN_GRADLEW, + env: { + EAS_BUILD_MAVEN_CACHE_URL: 'https://szymon.pl/maven/cache', + }, + }, + '/fake/path' + ); + + expect(err.errorCode).toBe('MAVEN_CACHE_ERROR'); + expect(err.userFacingErrorCode).toBe('EAS_BUILD_UNKNOWN_GRADLE_ERROR'); + expect(err.userFacingMessage).toBe( + `Gradle build failed with unknown error. See logs for the "Run gradlew" phase for more information.` + ); + }); + + it('does not throw MAVEN_CACHE_ERROR if "Could not find BlurView-version-2.0.3.jar" log is present', async () => { + const err = await resolveBuildPhaseErrorAsync( + new Error(), + [`Could not find BlurView-version-2.0.3.jar sth sthelse https://szymon.pl/maven/cache`], + { + job: { platform: Platform.ANDROID, mode: BuildMode.BUILD } as Job, + phase: BuildPhase.RUN_GRADLEW, + env: { + EAS_BUILD_MAVEN_CACHE_URL: 'https://szymon.pl/maven/cache', + }, + }, + '/fake/path' + ); + + expect(err.errorCode).toBe('EAS_BUILD_UNKNOWN_GRADLE_ERROR'); + expect(err.userFacingErrorCode).toBe('EAS_BUILD_UNKNOWN_GRADLE_ERROR'); + expect(err.userFacingMessage).toBe( + `Gradle build failed with unknown error. See logs for the "Run gradlew" phase for more information.` + ); + }); +}); diff --git a/packages/build-tools/src/buildErrors/__tests__/fixtures/xcode.log b/packages/build-tools/src/buildErrors/__tests__/fixtures/xcode.log new file mode 100644 index 0000000000..7b292b3569 --- /dev/null +++ b/packages/build-tools/src/buildErrors/__tests__/fixtures/xcode.log @@ -0,0 +1 @@ +/Users/expo/workingdir/build/managed/ios/Pods/Pods.xcodeproj: error: Signing for "EXConstants-EXConstants" requires a development team. Select a development team in the Signing & Capabilities editor. (in target 'EXConstants-EXConstants' from project 'Pods') diff --git a/packages/build-tools/src/buildErrors/buildErrorHandlers.ts b/packages/build-tools/src/buildErrors/buildErrorHandlers.ts new file mode 100644 index 0000000000..0d9cd4dd6e --- /dev/null +++ b/packages/build-tools/src/buildErrors/buildErrorHandlers.ts @@ -0,0 +1,361 @@ +import { BuildPhase, Platform } from '@expo/eas-build-job'; +import escapeRegExp from 'lodash/escapeRegExp'; + +import { ErrorContext, ErrorHandler } from './errors.types'; + +export class TrackedBuildError extends Error { + constructor( + public errorCode: string, + public message: string + ) { + super(message); + } +} + +export const buildErrorHandlers: ErrorHandler[] = [ + { + platform: Platform.IOS, + phase: BuildPhase.INSTALL_PODS, + // example log: + // CDN: trunk URL couldn't be downloaded: https://cdn.jsdelivr.net/cocoa/Specs/2/a/e/MultiplatformBleAdapter/0.0.3/MultiplatformBleAdapter.podspec.json Response: 429 429: Too Many Requests + regexp: /CDN: trunk URL couldn't be downloaded.* Response: 429 429: Too Many Requests/, + createError: () => + new TrackedBuildError('COCOAPODS_TO_MANY_REQUEST', 'cocoapods: too many requests'), + }, + { + phase: BuildPhase.INSTALL_DEPENDENCIES, + // Host key verification failed. + // fatal: Could not read from remote repository. + regexp: /Host key verification failed\.\nfatal: Could not read from remote repository/, + createError: () => new TrackedBuildError('NPM_INSTALL_SSH_AUTHENTICATION', 'Missing ssh key.'), + }, + { + phase: BuildPhase.INSTALL_DEPENDENCIES, + // error functions@1.0.0: The engine "node" is incompatible with this module. Expected version "14". Got "16.13.2" + // error Found incompatible module. + regexp: /The engine "node" is incompatible with this module\. Expected version/, + createError: () => + new TrackedBuildError('NODE_ENGINE_INCOMPATIBLE', 'node: Incompatible engine field.'), + }, + { + phase: BuildPhase.INSTALL_DEPENDENCIES, + // error An unexpected error occurred: "https://registry.yarnpkg.com/@react-native/normalize-color/-/normalize-color-2.0.0.tgz: Request failed \"500 Internal Server Error\"". + // or + // error An unexpected error occurred: "https://registry.yarnpkg.com/request/-/request-2.88.2.tgz: Request failed \"503 Service Unavailable\"". + regexp: /An unexpected error occurred: "https:\/\/registry.yarnpkg.com\/.*Request failed \\"5/, + createError: () => new TrackedBuildError('YARN_REGISTRY_5XX_RESPONSE', 'yarn: 5xx response.'), + }, + { + phase: BuildPhase.PREBUILD, + // Input is required, but Expo CLI is in non-interactive mode + // or + // CommandError: Input is required, but 'npx expo' is in non-interactive mode. + regexp: /Input is required, but .* is in non-interactive mode/, + createError: () => + new TrackedBuildError( + 'EXPO_CLI_INPUT_REQUIRED_ERROR', + `expo-cli: Input required in non-interactive mode.` + ), + }, + { + platform: Platform.IOS, + phase: BuildPhase.PREBUILD, + // [03:03:05] [ios.infoPlist]: withIosInfoPlistBaseMod: GoogleService-Info.plist is empty + regexp: /withIosInfoPlistBaseMod: GoogleService-Info\.plist is empty/, + createError: () => + new TrackedBuildError( + 'EXPO_CLI_EMPTY_GOOGLE_SERVICES_PLIST_ERROR', + `expo-cli: Empty GoogleService-Info.plist.` + ), + }, + { + platform: Platform.IOS, + phase: BuildPhase.PREBUILD, + // [01:52:04] [ios.xcodeproj]: withIosXcodeprojBaseMod: Path to GoogleService-Info.plist is not defined. Please specify the `expo.ios.googleServicesFile` field in app.json. + regexp: /withIosXcodeprojBaseMod: Path to GoogleService-Info\.plist is not defined/, + createError: () => + new TrackedBuildError( + 'EXPO_CLI_NOT_DEFINED_GOOGLE_SERVICES_PLIST_ERROR', + `expo-cli: Path to GoogleService-Info.plist is not defined.` + ), + }, + { + phase: BuildPhase.PREBUILD, + // Error: [android.dangerous]: withAndroidDangerousBaseMod: ENOENT: no such file or directory, open './assets/adaptive-icon.png' + // at Object.openSync (node:fs:585:3) + // at readFileSync (node:fs:453:35) + // at calculateHash (/home/expo/workingdir/build/node_modules/@expo/image-utils/build/Cache.js:14:91) + // at createCacheKey (/home/expo/workingdir/build/node_modules/@expo/image-utils/build/Cache.js:19:18) + // at Object.createCacheKeyWithDirectoryAsync (/home/expo/workingdir/build/node_modules/@expo/image-utils/build/Cache.js:24:33) + // at generateImageAsync (/home/expo/workingdir/build/node_modules/@expo/image-utils/build/Image.js:151:34) + // at async generateIconAsync (/home/expo/workingdir/build/node_modules/@expo/prebuild-config/build/plugins/icons/withAndroidIcons.js:369:11) + // at async /home/expo/workingdir/build/node_modules/@expo/prebuild-config/build/plugins/icons/withAndroidIcons.js:310:21 + // at async Promise.all (index 0) + // at async generateMultiLayerImageAsync (/home/expo/workingdir/build/node_modules/@expo/prebuild-config/build/plugins/icons/withAndroidIcons.js:306:3) + // or + // Error: [ios.dangerous]: withIosDangerousBaseMod: ENOENT: no such file or directory, open './assets/images/app_icon_staging.png' + // at Object.openSync (fs.js:497:3) + // at readFileSync (fs.js:393:35) + // at calculateHash (/Users/expo/workingdir/build/node_modules/@expo/image-utils/build/Cache.js:14:91) + // at createCacheKey (/Users/expo/workingdir/build/node_modules/@expo/image-utils/build/Cache.js:19:18) + // at Object.createCacheKeyWithDirectoryAsync (/Users/expo/workingdir/build/node_modules/@expo/image-utils/build/Cache.js:24:33) + // at generateImageAsync (/Users/expo/workingdir/build/node_modules/@expo/image-utils/build/Image.js:151:34) + // at async setIconsAsync (/Users/expo/workingdir/build/node_modules/@expo/prebuild-config/build/plugins/icons/withIosIcons.js:169:15) + // at async /Users/expo/workingdir/build/node_modules/@expo/prebuild-config/build/plugins/icons/withIosIcons.js:71:5 + // at async action (/Users/expo/workingdir/build/node_modules/@expo/config-plugins/build/plugins/withMod.js:235:23) + // at async interceptingMod (/Users/expo/workingdir/build/node_modules/@expo/config-plugins/build/plugins/withMod.js:126:21) + regexp: + /ENOENT: no such file or directory[\s\S]*prebuild-config\/build\/plugins\/icons\/with(Android|Ios)Icons\.js/, + createError: () => new TrackedBuildError('EXPO_CLI_MISSING_ICON', 'expo-cli: Missing icon.'), + }, + { + phase: BuildPhase.PREBUILD, + // Cannot determine which native SDK version your project uses because the module `expo` is not installed. Please install it with `yarn add expo` and try again. + regexp: + /Cannot determine which native SDK version your project uses because the module `expo` is not installed/, + createError: () => + new TrackedBuildError('EXPO_CLI_EXPO_PACKAGE_MISSING', 'expo-cli: "expo" package missing.'), + }, + { + phase: BuildPhase.INSTALL_PODS, + // The Swift pod `FirebaseCoreInternal` depends upon `GoogleUtilities`, which does not define modules. To opt into those targets generating module maps (which is necessary to import them from Swift when building as static + regexp: /The Swift pod .* depends upon .* which does not define modules/, + createError: () => + new TrackedBuildError( + 'SWIFT_POD_INCOMPATIBLE_DEPENDENCY', + 'pod: Swift pod depends on a pod that does not define modules.' + ), + }, + { + platform: Platform.IOS, + phase: BuildPhase.INSTALL_PODS, + // Adding spec repo `24-repository-cocoapods-proxy` with CDN `http://10.254.24.7:8081/repository/cocoapods-proxy/` + // [!] No podspec exists at path `/Users/expo/.cocoapods/repos/24-repository-cocoapods-proxy/Specs/1/9/2/libwebp/1.2.0/libwebp.podspec.json`. + regexp: /Adding spec repo .* with CDN .*\n\s*\[!\] No podspec exists at path `(.*)`/, + // Some pods are hosted on git registries that are not supported e.g. chromium.googlesource.com + createError: (match: RegExpMatchArray) => + new TrackedBuildError( + 'COCOAPODS_CACHE_INCOMPATIBLE_REPO_ERROR', + `cocoapods: Missing podspec ${match[1]}.` + ), + }, + { + platform: Platform.IOS, + phase: BuildPhase.INSTALL_PODS, + // [!] Invalid `Podfile` file: 783: unexpected token at 'info Run CLI with --verbose flag for more details. + regexp: + /\[!\] Invalid `Podfile` file: .* unexpected token at 'info Run CLI with --verbose flag for more details./, + createError: () => + new TrackedBuildError('NODE_ENV_PRODUCTION_DEFINED', 'npm: NODE_ENV=production was defined.'), + }, + ...[BuildPhase.INSTALL_DEPENDENCIES, BuildPhase.PREBUILD].map((phase) => ({ + phase, + // example log: + // [stderr] WARN tarball tarball data for @typescript-eslint/typescript-estree@5.26.0 (sha512-cozo/GbwixVR0sgfHItz3t1yXu521yn71Wj6PlYCFA3WPhy51CUPkifFKfBis91bDclGmAY45hhaAXVjdn4new==) seems to be corrupted. Trying again. + regexp: /tarball tarball data for ([^ ]*) .* seems to be corrupted. Trying again/, + createError: (match: RegExpMatchArray) => + new TrackedBuildError('NPM_CORRUPTED_PACKAGE', `npm: corrupted package ${match[1]}`), + })), + ...[BuildPhase.INSTALL_DEPENDENCIES, BuildPhase.PREBUILD].map((phase) => ({ + phase, + regexp: ({ env }: ErrorContext) => + env.EAS_BUILD_NPM_CACHE_URL + ? new RegExp(escapeRegExp(env.EAS_BUILD_NPM_CACHE_URL)) + : undefined, + createError: () => new TrackedBuildError('NPM_CACHE_ERROR', `npm: cache error`), + })), + { + platform: Platform.IOS, + phase: BuildPhase.INSTALL_PODS, + regexp: ({ env }: ErrorContext) => + env.EAS_BUILD_COCOAPODS_CACHE_URL ? /Error installing/ : undefined, + createError: () => + new TrackedBuildError( + 'COCOAPODS_CACHE_INSTALLING_POD_ERROR', + `cocoapods: error installing a pod using internal cache instance` + ), + }, + { + platform: Platform.IOS, + phase: BuildPhase.INSTALL_PODS, + regexp: ({ env }: ErrorContext) => + env.EAS_BUILD_COCOAPODS_CACHE_URL ? /No podspec exists at path/ : undefined, + createError: () => + new TrackedBuildError( + 'COCOAPODS_CACHE_NO_PODSPEC_EXISTS_AT_PATH_ERROR', + `cocoapods: error fetching a podspec through internal cache instance` + ), + }, + { + platform: Platform.IOS, + phase: BuildPhase.INSTALL_PODS, + regexp: ({ env }: ErrorContext) => + env.EAS_BUILD_COCOAPODS_CACHE_URL + ? new RegExp(escapeRegExp(env.EAS_BUILD_COCOAPODS_CACHE_URL)) + : undefined, + createError: () => new TrackedBuildError('COCOAPODS_CACHE_ERROR', `cocoapods: cache error`), + }, + { + phase: BuildPhase.INSTALL_PODS, + // [!] Invalid `Podfile` file: uninitialized constant Pod::Podfile::FlipperConfiguration. + regexp: /\[!\] Invalid `Podfile` file/, + createError: () => new TrackedBuildError('INVALID_PODFILE', 'pod: Invalid Podfile file.'), + }, + { + phase: BuildPhase.INSTALL_DEPENDENCIES, + // info There appears to be trouble with your network connection. Retrying... + regexp: /info There appears to be trouble with your network connection. Retrying/, + createError: () => + new TrackedBuildError( + 'YARN_INSTALL_TROUBLE_WITH_NETWORK_CONNECTION', + 'yarn: There appears to be trouble with your network connection' + ), + }, + { + phase: BuildPhase.RUN_GRADLEW, + platform: Platform.ANDROID, + // Android Gradle plugin requires Java 11 to run. You are currently using Java 1.8 + regexp: /Android Gradle plugin requires Java .* to run. You are currently using Java/, + createError: () => + new TrackedBuildError('INCOMPATIBLE_JAVA_VERSION', 'gradle: Incompatible java version.'), + }, + { + phase: BuildPhase.RUN_GRADLEW, + platform: Platform.ANDROID, + // /home/expo/workingdir/build/android/app/src/main/AndroidManifest.xml:27:9-33:20 Error: + // android:exported needs to be explicitly specified for element . Apps targeting Android 12 and higher are required to specify an explicit value for `android:exported` when the corresponding component has an intent filter defined. See https://developer.android.com/guide/topics/manifest/activity-element#exported for details. + regexp: + /Apps targeting Android 12 and higher are required to specify an explicit value for `android:exported`/, + createError: () => + new TrackedBuildError( + 'REQUIRE_EXPLICIT_EXPORTED_ANDROID_12', + 'Apps targeting Android 12 and higher are required to specify an explicit value for `android:exported`.' + ), + }, + { + phase: BuildPhase.RUN_GRADLEW, + platform: Platform.ANDROID, + // > A failure occurred while executing com.android.build.gradle.internal.res.Aapt2CompileRunnable + // > Android resource compilation failed + // ERROR:/home/expo/workingdir/build/android/app/src/main/res/mipmap-mdpi/ic_launcher.png: AAPT: error: failed to read PNG signature: file does not start with PNG signature. + regexp: /AAPT: error: failed to read PNG signature: file does not start with PNG signature/, + createError: () => + new TrackedBuildError('INVALID_PNG_SIGNATURE', 'gradle: Invalid PNG signature.'), + }, + { + phase: BuildPhase.RUN_GRADLEW, + platform: Platform.ANDROID, + // Execution failed for task ':app:processReleaseGoogleServices'. + // > Malformed root json + regexp: /Execution failed for task ':app:process.*GoogleServices'.*\s.*Malformed root json/, + createError: () => + new TrackedBuildError( + 'GRADLE_MALFORMED_GOOGLE_SERVICES_JSON', + 'gradle: Malformed google-services.json.' + ), + }, + { + phase: BuildPhase.RUN_GRADLEW, + platform: Platform.ANDROID, + // Execution failed for task ':app:processDebugGoogleServices'. + // > Missing project_info object + regexp: + /Execution failed for task ':app:process.*GoogleServices'.*\s.*Missing project_info object/, + createError: () => + new TrackedBuildError( + 'GRADLE_MALFORMED_GOOGLE_SERVICES_JSON', + 'gradle: Missing project_info object.' + ), + }, + { + phase: BuildPhase.RUN_GRADLEW, + platform: Platform.ANDROID, + // Execution failed for task ':app:bundleReleaseJsAndAssets'. + // > Process 'command 'node'' finished with non-zero exit value 1 + regexp: + /Execution failed for task ':app:bundleReleaseJsAndAssets'.*\s.*Process 'command 'node'' finished with non-zero exit value/, + createError: () => + new TrackedBuildError( + 'GRADLE_BUILD_BUNDLER_ERROR', + "gradle: ':app:bundleReleaseJsAndAssets' failed." + ), + }, + { + platform: Platform.ANDROID, + phase: BuildPhase.RUN_GRADLEW, + // > Could not resolve org.jetbrains.kotlin:kotlin-annotation-processing-gradle:1.5.10. + // Required by: + // project :expo-updates + // > Could not resolve org.jetbrains.kotlin:kotlin-annotation-processing-gradle:1.5.10. + // > Could not get resource 'https://lsdkfjsdlkjf.com/android/releases/org/jetbrains/kotlin/kotlin-annotation-processing-gradle/1.5.10/kotlin-annotation-processing-gradle-1.5.10.pom'. + // > Could not HEAD 'https://slkdfjldskjfl.com/android/releases/org/jetbrains/kotlin/kotlin-annotation-processing-gradle/1.5.10/kotlin-annotation-processing-gradle-1.5.10.pom'. + // > Connect to sdlkfjsdlkf.com:443 [slkdfjdslk.com/38.178.101.5] failed: connect timed out + regexp: /Could not get resource.*\n.*Could not HEAD 'https/, + createError: () => + new TrackedBuildError('MAVEN_REGISTRY_CONNECTION_ERROR', `maven: registry connection error`), + }, + { + platform: Platform.ANDROID, + phase: BuildPhase.RUN_GRADLEW, + regexp: ({ env }: ErrorContext) => + env.EAS_BUILD_MAVEN_CACHE_URL + ? // The BlurView 2.0.3 jar was removed from jitpack.io + // and it caused false positive MAVEN_CACHE_ERROR errors being reported. + new RegExp( + `^(?!.*Could not find BlurView-version-2\\.0\\.3\\.jar).*${escapeRegExp( + env.EAS_BUILD_MAVEN_CACHE_URL + )}` + ) + : undefined, + createError: () => new TrackedBuildError('MAVEN_CACHE_ERROR', `maven: cache error`), + }, + { + phase: BuildPhase.RUN_FASTLANE, + platform: Platform.IOS, + // error: exportArchive: exportOptionsPlist error for key "iCloudContainerEnvironment": expected one of {Development, Production}, but no value was provided + regexp: /exportArchive: exportOptionsPlist error for key "iCloudContainerEnvironment"/, + createError: () => + new TrackedBuildError( + 'MISSING_ICLOUD_CONTAINER_ENVIRONMENT', + 'fastlane: Missing iCloudContainerEnvironment in exportOptionsPlist.' + ), + }, + { + phase: BuildPhase.RUN_FASTLANE, + platform: Platform.IOS, + // The following build commands failed: + // PhaseScriptExecution [CP-User]\ Generate\ app.manifest\ for\ expo-updates /Users/expo/Library/Developer/Xcode/DerivedData/Kenkohub-eqseedlxbgrzjqagscbclhbtstwh/Build/Intermediates.noindex/ArchiveIntermediates/Kenkohub/IntermediateBuildFilesPath/Pods.build/Release-iphoneos/EXUpdates.build/Script-BB6B5FD28815C045A20B2E5E3FEEBD6E.sh (in target 'EXUpdates' from project 'Pods') + regexp: + /The following build commands failed.*\s.*\[CP-User\]\\ Generate\\ app\.manifest\\ for\\ expo-updates/, + createError: () => + new TrackedBuildError( + 'XCODE_BUILD_UPDATES_PHASE_SCRIPT', + 'fastlane: Generating app.manifest for expo-updates failed.' + ), + }, + { + phase: BuildPhase.RUN_FASTLANE, + platform: Platform.IOS, + // The following build commands failed: + // CompileC /Users/expo/Library/Developer/Xcode/DerivedData/Docent-amxxhphjfdtkpxecgidgzvwvnvtc/Build/Intermediates.noindex/ArchiveIntermediates/Docent/IntermediateBuildFilesPath/Pods.build/Release-iphoneos/Flipper-Folly.build/Objects-normal/arm64/SSLErrors.o /Users/expo/workingdir/build/ios/Pods/Flipper-Folly/folly/io/async/ssl/SSLErrors.cpp normal arm64 c++ com.apple.compilers.llvm.clang.1_0.compiler (in target 'Flipper-Folly' from project 'Pods') + regexp: /in target 'Flipper-Folly' from project 'Pods'/, + createError: () => + new TrackedBuildError( + 'FLIPPER_FOLLY_COMPILE_ERROR', + 'fastlane: Flipper-Folly compile error.' + ), + }, + { + phase: BuildPhase.RUN_FASTLANE, + platform: Platform.IOS, + // The following build commands failed: + // PhaseScriptExecution Bundle\ React\ Native\ code\ and\ images /Users/expo/Library/Developer/Xcode/DerivedData/cnaxwpahkhcjluhigkcwrturapmm/Build/Intermediates.noindex/ArchiveIntermediates/Test/IntermediateBuildFilesPath/Test.build/Release-iphoneos/Test.build/Script-00DD1BFF151E006B06BC.sh (in target 'Test' from project 'Test') + regexp: + /The following build commands failed.*\s.*PhaseScriptExecution Bundle\\ React\\ Native\\ code\\ and\\ images \/Users\/expo/, + createError: () => + new TrackedBuildError( + 'XCODE_BUILD_BUNDLER_ERROR', + 'fastlane: Bundle React Native code and images failed.' + ), + }, +]; diff --git a/packages/build-tools/src/buildErrors/detectError.ts b/packages/build-tools/src/buildErrors/detectError.ts new file mode 100644 index 0000000000..7628bc761b --- /dev/null +++ b/packages/build-tools/src/buildErrors/detectError.ts @@ -0,0 +1,107 @@ +import { BuildPhase, errors } from '@expo/eas-build-job'; +import fs from 'fs-extra'; + +import { findXcodeBuildLogsPathAsync } from '../ios/xcodeBuildLogs'; + +import { ErrorContext, ErrorHandler, XCODE_BUILD_PHASE } from './errors.types'; +import { userErrorHandlers } from './userErrorHandlers'; +import { buildErrorHandlers } from './buildErrorHandlers'; + +async function maybeReadXcodeBuildLogs( + phase: BuildPhase, + buildLogsDirectory: string +): Promise { + if (phase !== BuildPhase.RUN_FASTLANE) { + return; + } + + try { + const xcodeBuildLogsPath = await findXcodeBuildLogsPathAsync(buildLogsDirectory); + + if (!xcodeBuildLogsPath) { + return; + } + + return await fs.readFile(xcodeBuildLogsPath, 'utf-8'); + } catch { + return undefined; + } +} + +function resolveError( + errorHandlers: ErrorHandler[], + logLines: string[], + errorContext: ErrorContext, + xcodeBuildLogs?: string +): TError | undefined { + const { job, phase } = errorContext; + const { platform } = job; + const logs = logLines.join('\n'); + const handlers = errorHandlers + .filter((handler) => handler.platform === platform || !handler.platform) + .filter( + (handler) => + (handler.phase === XCODE_BUILD_PHASE && phase === BuildPhase.RUN_FASTLANE) || + handler.phase === phase || + !handler.phase + ) + .filter((handler) => ('mode' in job && handler.mode === job.mode) || !handler.mode); + + for (const handler of handlers) { + const regexp = + typeof handler.regexp === 'function' ? handler.regexp(errorContext) : handler.regexp; + if (!regexp) { + continue; + } + const match = + handler.phase === XCODE_BUILD_PHASE ? xcodeBuildLogs?.match(regexp) : logs.match(regexp); + + if (match) { + return handler.createError(match, errorContext); + } + } + return undefined; +} + +export async function resolveBuildPhaseErrorAsync( + error: any, + logLines: string[], + errorContext: ErrorContext, + buildLogsDirectory: string +): Promise { + const { phase } = errorContext; + if (error instanceof errors.BuildError) { + return error; + } + const xcodeBuildLogs = await maybeReadXcodeBuildLogs(phase, buildLogsDirectory); + const userFacingError = + error instanceof errors.UserFacingError + ? error + : resolveError(userErrorHandlers, logLines, errorContext, xcodeBuildLogs) ?? + new errors.UnknownError(errorContext.phase); + const buildError = resolveError(buildErrorHandlers, logLines, errorContext, xcodeBuildLogs); + + const isUnknownUserError = + !userFacingError || + ( + [ + errors.ErrorCode.UNKNOWN_ERROR, + errors.ErrorCode.UNKNOWN_GRADLE_ERROR, + errors.ErrorCode.UNKNOWN_FASTLANE_ERROR, + ] as string[] + ).includes(userFacingError.errorCode); + const message = + (isUnknownUserError ? buildError?.message : userFacingError.message) ?? userFacingError.message; + const errorCode = + (isUnknownUserError ? buildError?.errorCode : userFacingError.errorCode) ?? + userFacingError.errorCode; + + return new errors.BuildError(message, { + errorCode, + userFacingErrorCode: userFacingError.errorCode, + userFacingMessage: userFacingError.message, + docsUrl: userFacingError.docsUrl, + innerError: error, + buildPhase: phase, + }); +} diff --git a/packages/build-tools/src/buildErrors/errors.types.ts b/packages/build-tools/src/buildErrors/errors.types.ts new file mode 100644 index 0000000000..fda1170f01 --- /dev/null +++ b/packages/build-tools/src/buildErrors/errors.types.ts @@ -0,0 +1,17 @@ +import { BuildMode, BuildPhase, Env, Job, Platform } from '@expo/eas-build-job'; + +export interface ErrorContext { + phase: BuildPhase; + job: TJob; + env: Env; +} + +export const XCODE_BUILD_PHASE = 'XCODE_BUILD'; + +export interface ErrorHandler { + regexp: RegExp | ((ctx: ErrorContext) => RegExp | undefined); + platform?: Platform; + phase?: BuildPhase | typeof XCODE_BUILD_PHASE; + mode?: BuildMode; + createError: (matchResult: RegExpMatchArray, errCtx: ErrorContext) => T | undefined; +} diff --git a/packages/build-tools/src/buildErrors/userErrorHandlers.ts b/packages/build-tools/src/buildErrors/userErrorHandlers.ts new file mode 100644 index 0000000000..cd7d90d2e3 --- /dev/null +++ b/packages/build-tools/src/buildErrors/userErrorHandlers.ts @@ -0,0 +1,311 @@ +import { BuildMode, BuildPhase, errors, Platform, Workflow } from '@expo/eas-build-job'; + +import { ErrorHandler, XCODE_BUILD_PHASE } from './errors.types'; + +import UserFacingError = errors.UserFacingError; + +export const userErrorHandlers: ErrorHandler[] = [ + { + platform: Platform.IOS, + phase: BuildPhase.INSTALL_PODS, + regexp: /requires CocoaPods version/, + // example log: + // [!] `React` requires CocoaPods version `>= 1.10.1`, which is not satisfied by your current version, `1.10.0`. + createError: () => + new UserFacingError( + 'EAS_BUILD_UNSUPPORTED_COCOAPODS_VERSION_ERROR', + `Your project requires a newer version of CocoaPods. You can update it in the build profile in eas.json by either: +- changing the current version under key "cocoapods" +- switching to an image that supports that version under key "image"`, + 'https://docs.expo.dev/build-reference/eas-json/' + ), + }, + { + platform: Platform.IOS, + phase: BuildPhase.RUN_FASTLANE, + regexp: /Could not find 'bundler' (.*) required by your/, + // example log: + // /System/Library/Frameworks/Ruby.framework/Versions/2.6/usr/lib/ruby/2.6.0/rubygems/dependency.rb:313:in `to_specs': Could not find 'bundler' (2.2.3) required by your /Users/expo/project/build/ios/Gemfile.lock. (Gem::MissingSpecVersionError) + createError: () => + new UserFacingError( + 'EAS_BUILD_UNSUPPORTED_BUNDLER_VERSION_ERROR', + `Your project requires a different version of the Ruby "bundler" program than the version installed in this EAS Build environment. You can specify which version of "bundler" to install by specifying the version under "build"→[buildProfileName]→"ios"→"bundler" in eas.json.`, + 'https://docs.expo.dev/build-reference/eas-json/' + ), + }, + { + platform: Platform.ANDROID, + phase: BuildPhase.RUN_GRADLEW, + // example log: + // > Failed to read key keyalias from store "/build/workingdir/build/generic/keystore-5787e6af-3002-4cb7-8a57-3e73d13313c2.jks": Invalid keystore format + regexp: /Invalid keystore format/, + createError: () => + new UserFacingError( + 'EAS_BUILD_INVALID_KEYSTORE_FORMAT_ERROR', + 'The keystore used in this build is malformed or it has an unsupported type. Make sure you provided the correct file.' + ), + }, + { + platform: Platform.ANDROID, + phase: BuildPhase.RUN_GRADLEW, + // example log: + // > Failed to read key keyalias from store "/build/workingdir/build/generic/keystore-286069a8-4bb9-48a6-add9-acf6b58ea06d.jks": null + regexp: /Failed to read key[^\n]+from store/, + createError: () => + new UserFacingError( + 'EAS_BUILD_INVALID_KEYSTORE_ALIAS_ERROR', + 'The alias specified for this keystore does not exist. Make sure you specified the correct value.' + ), + }, + { + platform: Platform.ANDROID, + phase: BuildPhase.PREBUILD, + // example log: + // [15:42:05] Error: Cannot copy google-services.json from /build/workingdir/build/managed/abc to /build/workingdir/build/managed/android/app/google-services.json + // or + // [11:17:29] [android.dangerous]: withAndroidDangerousBaseMod: Cannot copy google-services.json from /home/expo/workingdir/build/test/test-google-services.json to /home/expo/workingdir/build/android/app/google-services.json. Please make sure the source and destination paths exist. + // [11:17:29] Error: [android.dangerous]: withAndroidDangerousBaseMod: Cannot copy google-services.json from /home/expo/workingdir/build/test/test-google-services.json to /home/expo/workingdir/build/android/app/google-services.json. Please make sure the source and destination paths exist. + regexp: /Cannot copy google-services\.json/, + createError: () => + new UserFacingError( + 'EAS_BUILD_MISSING_GOOGLE_SERVICES_JSON_ERROR', + '"google-services.json" is missing, make sure that the file exists. Remember that EAS Build only uploads the files tracked by git. Use EAS environment variables to provide EAS Build with the file.', + 'https://docs.expo.dev/eas/environment-variables/#file-environment-variables' + ), + }, + { + platform: Platform.ANDROID, + phase: BuildPhase.RUN_GRADLEW, + // Execution failed for task ':app:processReleaseGoogleServices'. + // > File google-services.json is missing. The Google Services Plugin cannot function without it. + // Searched Location: + regexp: + /File google-services\.json is missing\. The Google Services Plugin cannot function without it/, + createError: () => + new UserFacingError( + 'EAS_BUILD_MISSING_GOOGLE_SERVICES_JSON_ERROR', + '"google-services.json" is missing, make sure that the file exists. Remember that EAS Build only uploads the files tracked by git. Use EAS environment variables to provide EAS Build with the file.', + 'https://docs.expo.dev/eas/environment-variables/#file-environment-variables' + ), + }, + { + platform: Platform.IOS, + phase: BuildPhase.PREBUILD, + // example log: + // [08:44:18] ENOENT: no such file or directory, copyfile '/Users/expo/workingdir/build/managed/abc' -> '/Users/expo/workingdir/build/managed/ios/testapp/GoogleService-Info.plist' + regexp: /ENOENT: no such file or directory, copyfile .*GoogleService-Info.plist/, + createError: () => + new UserFacingError( + 'EAS_BUILD_MISSING_GOOGLE_SERVICES_PLIST_ERROR', + '"GoogleService-Info.plist" is missing, make sure that the file exists. Remember that EAS Build only uploads the files tracked by git. Use EAS environment variables to provide EAS Build with the file.', + 'https://docs.expo.dev/eas/environment-variables/#file-environment-variables' + ), + }, + { + platform: Platform.IOS, + phase: BuildPhase.INSTALL_PODS, + // example log: + // [!] CocoaPods could not find compatible versions for pod "react-native-google-maps" + // In Podfile: + // react-native-google-maps (from `/Users/expo/workingdir/build/node_modules/react-native-maps`) + // Specs satisfying the `react-native-google-maps (from `/Users/expo/workingdir/build/node_modules/react-native-maps`)` dependency were found, but they required a higher minimum deployment target. + // Error: Compatible versions of some pods could not be resolved. + regexp: + /Specs satisfying the `(.*)` dependency were found, but they required a higher minimum deployment target/, + createError: (_, { job }) => { + return new UserFacingError( + 'EAS_BUILD_HIGHER_MINIMUM_DEPLOYMENT_TARGET_ERROR', + `Some pods require a higher minimum deployment target. +${ + 'type' in job && job.type === Workflow.MANAGED + ? 'You can use the expo-build-properties config plugin (https://docs.expo.dev/versions/latest/sdk/build-properties/) to override the default native build properties and set a different minimum deployment target.' + : 'You need to manually update the minimum deployment target in your project to resolve this issue.' +} +` + ); + }, + }, + { + platform: Platform.IOS, + phase: BuildPhase.INSTALL_PODS, + // example log: + // [!] CocoaPods could not find compatible versions for pod "Firebase/Core": + // In snapshot (Podfile.lock): + // Firebase/Core (= 6.14.0) + // In Podfile: + // EXFirebaseCore (from `../node_modules/expo-firebase-core/ios`) was resolved to 3.0.0, which depends on + // Firebase/Core (= 7.7.0) + // You have either: + // * out-of-date source repos which you can update with `pod repo update` or with `pod install --repo-update`. + // * changed the constraints of dependency `Firebase/Core` inside your development pod `EXFirebaseCore`. + // You should run `pod update Firebase/Core` to apply changes you've made. + regexp: /CocoaPods could not find compatible versions for pod /, + createError: () => { + return new UserFacingError( + 'EAS_BUILD_INCOMPATIBLE_PODS_ERROR', + `Compatible versions of some pods could not be resolved. +You are seeing this error because either: + - Some of the pods used in your project depend on different versions of the same pod. See logs for more information. + - If you are caching Podfile.lock using "cache" field in eas.json, then versions there might not match required values in Podspecs of some installed libraries. To fix this, you can re-run build command with "--clear-cache" option, or select "Clear cache and retry build" on the build page. +` + ); + }, + }, + { + phase: BuildPhase.INSTALL_DEPENDENCIES, + // example log: + // [stderr] npm ERR! Fix the upstream dependency conflict, or retry + // [stderr] npm ERR! this command with --force, or --legacy-peer-deps + // [stderr] npm ERR! to accept an incorrect (and potentially broken) dependency resolution. + regexp: + /Fix the upstream dependency conflict, or retry.*\s.*this command with --force, or --legacy-peer-deps/, + createError: (matchResult: RegExpMatchArray) => { + if (matchResult.length >= 2) { + return new UserFacingError( + 'EAS_BUILD_NPM_CONFLICTING_PEER_DEPENDENCIES', + `Some of your peer dependencies are not compatible. The recommended approach is to fix your dependencies by resolving any conflicts listed by "npm install". As a temporary workaround you can: +- Add ".npmrc" file with "legacy-peer-deps=true" and commit that to your repo. +- Delete package-lock.json and use yarn instead. It does not enforce peer dependencies. +- Downgrade to older version of npm on EAS Build, by adding "npm install -g npm@version" in "eas-build-pre-install" script in package.json.` + ); + } + return undefined; + }, + }, + { + phase: BuildPhase.INSTALL_DEPENDENCIES, + // example log: + // [stderr] error https://github.com/expo/react-native/archive/sdk-41.0.0.tar.gz: Integrity check failed for "react-native" (computed integrity doesn't match our records, got "sha512-3jHI2YufrJi7eIABRf/DN/I2yOkmIZ0vAyezTz+PAUJiEs4v//5LLojWEU+W53AZsnuaEMcl/4fVy4bd+OuUbA== sha1-o9QuQTXIkc8VozXPaZIullB9a40=") + regexp: /Integrity check failed for "(.*)" \(computed integrity doesn't match our records, got/, + createError: (matchResult: RegExpMatchArray) => { + if (matchResult.length >= 2) { + return new UserFacingError( + 'EAS_BUILD_YARN_LOCK_CHECKSUM_ERROR', + `Checksum for package "${matchResult[1]}" does not match value in registry. To fix that: +- run "yarn cache clean" +- remove yarn.lock (or only the section for that package) +- run "yarn install --force"` + ); + } + return undefined; + }, + }, + { + phase: BuildPhase.INSTALL_DEPENDENCIES, + // example log: + // yarn install v1.22.17 + // [1/4] Resolving packages... + // [2/4] Fetching packages... + // [1/4] Resolving packages... + // [2/4] Fetching packages... + // [stderr] error https://registry.yarnpkg.com/jest-util/-/jest-util-26.6.2.tgz: Extracting tar content of undefined failed, the file appears to be corrupt: "ENOENT: no such file or directory, chmod '/Users/expo/Library/Caches/Yarn/v6/npm-jest-util-26.6.2-907535dbe4d5a6cb4c47ac9b926f6af29576cbc1-integrity/node_modules/jest-util/build/pluralize.d.ts'" + regexp: + /\[1\/4\] Resolving packages...\s*\[2\/4\] Fetching packages...\s*\[1\/4\] Resolving packages...\s*\[2\/4\] Fetching packages.../, + createError: (matchResult: RegExpMatchArray) => { + if (matchResult) { + return new UserFacingError( + 'EAS_BUILD_YARN_MULTIPLE_INSTANCES_ERROR', + `One of project dependencies is starting new install process while the main one is still in progress, which might result in corrupted packages. Most likely the reason for error is "prepare" script in git-referenced dependency of your project. Learn more: https://github.com/yarnpkg/yarn/issues/7212#issuecomment-493720324` + ); + } + return undefined; + }, + }, + { + platform: Platform.IOS, + phase: XCODE_BUILD_PHASE, + // Prepare packages + // Computing target dependency graph and provisioning inputs + // Create build description + // Build description signature: 33a5c28977280822abe5e7bd7fe02529 + // Build description path: /Users/expo/Library/Developer/Xcode/DerivedData/testapp-fazozgerxcvvfifkipojsjftgyih/Build/Intermediates.noindex/ArchiveIntermediates/testapp/IntermediateBuildFilesPath/XCBuildData/33a5c28977280822abe5e7bd7fe02529-desc.xcbuild + // note: Building targets in dependency order + // /Users/expo/workingdir/build/managed/ios/Pods/Pods.xcodeproj: error: Signing for "EXConstants-EXConstants" requires a development team. Select a development team in the Signing & Capabilities editor. (in target 'EXConstants-EXConstants' from project 'Pods') + // /Users/expo/workingdir/build/managed/ios/Pods/Pods.xcodeproj: error: Signing for "React-Core-AccessibilityResources" requires a development team. Select a development team in the Signing & Capabilities editor. (in target 'React-Core-AccessibilityResources' from project 'Pods') + // warning: Run script build phase '[CP-User] Generate app.manifest for expo-updates' will be run during every build because it does not specify any outputs. To address this warning, either add output dependencies to the script phase, or configure it to run in every build by unchecking "Based on dependency analysis" in the script phase. (in target 'EXUpdates' from project 'Pods') + // warning: Run script build phase 'Start Packager' will be run during every build because it does not specify any outputs. To address this warning, either add output dependencies to the script phase, or configure it to run in every build by unchecking "Based on dependency analysis" in the script phase. (in target 'testapp' from project 'testapp') + // warning: Run script build phase 'Bundle React Native code and images' will be run during every build because it does not specify any outputs. To address this warning, either add output dependencies to the script phase, or configure it to run in every build by unchecking "Based on dependency analysis" in the script phase. (in target 'testapp' from project 'testapp') + // warning: Run script build phase '[CP-User] Generate app.config for prebuilt Constants.manifest' will be run during every build because it does not specify any outputs. To address this warning, either add output dependencies to the script phase, or configure it to run in every build by unchecking "Based on dependency analysis" in the script phase. (in target 'EXConstants' from project 'Pods') + // /Users/expo/workingdir/build/managed/ios/Pods/Pods.xcodeproj: error: Signing for "EXUpdates-EXUpdates" requires a development team. Select a development team in the Signing & Capabilities editor. (in target 'EXUpdates-EXUpdates' from project 'Pods') + regexp: /error: Signing for "[a-zA-Z-0-9_]+" requires a development team/, + createError: (_, { job }) => + 'type' in job && job.type === Workflow.MANAGED + ? new UserFacingError( + 'XCODE_RESOURCE_BUNDLE_CODE_SIGNING_ERROR', + `Starting from Xcode 14, resource bundles are signed by default, which requires setting the development team for each resource bundle target. +To resolve this issue, downgrade to an older Xcode version using the "image" field in eas.json, or upgrade to SDK 46 or higher.`, + 'https://docs.expo.dev/build-reference/infrastructure/#ios-build-server-configurations' + ) + : new UserFacingError( + 'XCODE_RESOURCE_BUNDLE_CODE_SIGNING_ERROR', + `Starting from Xcode 14, resource bundles are signed by default, which requires setting the development team for each resource bundle target. +To resolve this issue, downgrade to an older Xcode version using the "image" field in eas.json, or turn off signing resource bundles in your Podfile: https://expo.fyi/r/disable-bundle-resource-signing`, + 'https://docs.expo.dev/build-reference/infrastructure/#ios-build-server-configurations' + ), + }, + { + platform: Platform.ANDROID, + phase: BuildPhase.RUN_GRADLEW, + regexp: /.*/, + createError: () => + new UserFacingError( + errors.ErrorCode.UNKNOWN_GRADLE_ERROR, + 'Gradle build failed with unknown error. See logs for the "Run gradlew" phase for more information.' + ), + }, + { + platform: Platform.IOS, + phase: BuildPhase.RUN_FASTLANE, + mode: BuildMode.RESIGN, + regexp: /No provisioning profile for application: '(.+)' with bundle identifier '(.+)'/, + createError: () => + new UserFacingError( + 'EAS_BUILD_RESIGN_PROVISIONING_PROFILE_MISMATCH_ERROR', + `The bundle identifier in provisioning profile used to resign the app does not match the bundle identifier of the app selected to be resigned. See logs above for more information.` + ), + }, + { + platform: Platform.IOS, + phase: BuildPhase.RUN_FASTLANE, + mode: BuildMode.RESIGN, + regexp: /.*/, + createError: () => + new UserFacingError( + errors.ErrorCode.UNKNOWN_FASTLANE_RESIGN_ERROR, + `The "Run fastlane" step failed with an unknown error.` + ), + }, + { + platform: Platform.IOS, + phase: XCODE_BUILD_PHASE, + // MkDir /Users/expo/workingdir/build/packages/apps/NFLNetwork/ios/build/Build/Products/Release-iphonesimulator/expo-dev-launcher/EXDevLauncher.bundle (in target 'expo-dev-launcher-EXDevLauncher' from project 'Pods') + // cd /Users/expo/workingdir/build/packages/apps/NFLNetwork/ios/Pods + // /bin/mkdir -p /Users/expo/workingdir/build/packages/apps/NFLNetwork/ios/build/Build/Products/Release-iphonesimulator/expo-dev-launcher/EXDevLauncher.bundle + // ProcessXCFramework /Users/expo/workingdir/build/packages/video/ios/Vendor/dependency/ProgrammaticAccessLibrary.xcframework /Users/expo/workingdir/build/packages/apps/NFLNetwork/ios/build/Build/Products/Release-iphonesimulator/ProgrammaticAccessLibrary.framework ios simulator + // cd /Users/expo/workingdir/build/packages/apps/NFLNetwork/ios + // builtin-process-xcframework --xcframework /Users/expo/workingdir/build/packages/video/ios/Vendor/dependency/ProgrammaticAccessLibrary.xcframework --platform ios --environment simulator --target-path /Users/expo/workingdir/build/packages/apps/NFLNetwork/ios/build/Build/Products/Release-iphonesimulator + // /Users/expo/workingdir/build/packages/video/ios/Vendor/dependency/ProgrammaticAccessLibrary.xcframework:1:1: error: The signature of “ProgrammaticAccessLibrary.xcframework” cannot be verified. + // note: A sealed resource is missing or invalid + // note: /Users/expo/workingdir/build/packages/video/ios/Vendor/dependency/ProgrammaticAccessLibrary.xcframework: a sealed resource is missing or invalid + // file modified: /Users/expo/workingdir/build/packages/video/ios/Vendor/dependency/ProgrammaticAccessLibrary.xcframework/ios-arm64_x86_64-simulator/ProgrammaticAccessLibrary.framework/ProgrammaticAccessLibrary + // file modified: /Users/expo/workingdir/build/packages/video/ios/Vendor/dependency/ProgrammaticAccessLibrary.xcframework/ios-arm64/ProgrammaticAccessLibrary.framework/ProgrammaticAccessLibrary + regexp: /error: .+/g, + createError: (matchResult) => + new UserFacingError( + 'XCODE_BUILD_ERROR', + `The "Run fastlane" step failed because of an error in the Xcode build process. We automatically detected following errors in your Xcode build logs:\n${matchResult + .map((match) => `- ${match.replace('error: ', '')}`) + .join('\n')}\nRefer to "Xcode Logs" below for additional, more detailed logs.` + ), + }, + { + platform: Platform.IOS, + phase: BuildPhase.RUN_FASTLANE, + regexp: /.*/, + createError: () => + new UserFacingError( + errors.ErrorCode.UNKNOWN_FASTLANE_ERROR, + `The "Run fastlane" step failed with an unknown error. Refer to "Xcode Logs" below for additional, more detailed logs.` + ), + }, +]; diff --git a/packages/build-tools/src/builders/__tests__/custom.test.ts b/packages/build-tools/src/builders/__tests__/custom.test.ts new file mode 100644 index 0000000000..208cbfdba1 --- /dev/null +++ b/packages/build-tools/src/builders/__tests__/custom.test.ts @@ -0,0 +1,77 @@ +import { BuildJob } from '@expo/eas-build-job'; +import { vol } from 'memfs'; + +import { runCustomBuildAsync } from '../custom'; +import { BuildContext } from '../../context'; +import { createMockLogger } from '../../__tests__/utils/logger'; +import { createTestIosJob } from '../../__tests__/utils/job'; +import { findAndUploadXcodeBuildLogsAsync } from '../../ios/xcodeBuildLogs'; +import { prepareProjectSourcesAsync } from '../../common/projectSources'; + +jest.mock('../../common/projectSources'); +jest.mock('../../ios/xcodeBuildLogs'); + +const findAndUploadXcodeBuildLogsAsyncMock = jest.mocked(findAndUploadXcodeBuildLogsAsync); + +describe(runCustomBuildAsync, () => { + let ctx: BuildContext; + + beforeEach(() => { + const job = createTestIosJob(); + + jest.mocked(prepareProjectSourcesAsync).mockImplementation(async () => { + vol.mkdirSync('/workingdir/env', { recursive: true }); + vol.mkdirSync('/workingdir/temporary-custom-build', { recursive: true }); + vol.fromJSON( + { + 'test.yaml': ` + build: + steps: + - eas/checkout + `, + }, + '/workingdir/temporary-custom-build' + ); + return { handled: true }; + }); + + ctx = new BuildContext( + { + ...job, + customBuildConfig: { + path: 'test.yaml', + }, + }, + { + workingdir: '/workingdir', + logBuffer: { getLogs: () => [], getPhaseLogs: () => [] }, + logger: createMockLogger(), + env: { + __API_SERVER_URL: 'http://api.expo.test', + }, + uploadArtifact: jest.fn(), + } + ); + }); + + it('calls findAndUploadXcodeBuildLogsAsync in an iOS job if its artifacts is empty', async () => { + await runCustomBuildAsync(ctx); + expect(findAndUploadXcodeBuildLogsAsyncMock).toHaveBeenCalled(); + }); + + it('does not call findAndUploadXcodeBuildLogsAsync in an iOS job if artifacts is already present', async () => { + ctx.artifacts.XCODE_BUILD_LOGS = 'uploaded'; + await runCustomBuildAsync(ctx); + expect(findAndUploadXcodeBuildLogsAsyncMock).not.toHaveBeenCalled(); + }); + + it('retries checking out the project', async () => { + jest.mocked(prepareProjectSourcesAsync).mockImplementationOnce(async () => { + throw new Error('Failed to clone repository'); + }); + + await runCustomBuildAsync(ctx); + + expect(prepareProjectSourcesAsync).toHaveBeenCalledTimes(2); + }); +}); diff --git a/packages/build-tools/src/builders/android.ts b/packages/build-tools/src/builders/android.ts new file mode 100644 index 0000000000..81589b73ac --- /dev/null +++ b/packages/build-tools/src/builders/android.ts @@ -0,0 +1,193 @@ +import path from 'path'; + +import { Android, BuildMode, BuildPhase, Workflow } from '@expo/eas-build-job'; +import nullthrows from 'nullthrows'; + +import { Artifacts, BuildContext, SkipNativeBuildError } from '../context'; +import { + configureExpoUpdatesIfInstalledAsync, + resolveRuntimeVersionForExpoUpdatesIfConfiguredAsync, +} from '../utils/expoUpdates'; +import { + runGradleCommand, + ensureLFLineEndingsInGradlewScript, + resolveGradleCommand, +} from '../android/gradle'; +import { uploadApplicationArchive } from '../utils/artifacts'; +import { Hook, runHookIfPresent } from '../utils/hooks'; +import { restoreCredentials } from '../android/credentials'; +import { configureBuildGradle } from '../android/gradleConfig'; +import { setupAsync } from '../common/setup'; +import { prebuildAsync } from '../common/prebuild'; +import { prepareExecutableAsync } from '../utils/prepareBuildExecutable'; +import { eagerBundleAsync, shouldUseEagerBundle } from '../common/eagerBundle'; +import { cacheStatsAsync, restoreCcacheAsync } from '../steps/functions/restoreBuildCache'; +import { saveCcacheAsync } from '../steps/functions/saveBuildCache'; + +import { runBuilderWithHooksAsync } from './common'; +import { runCustomBuildAsync } from './custom'; + +export default async function androidBuilder(ctx: BuildContext): Promise { + if (ctx.job.mode === BuildMode.BUILD) { + await prepareExecutableAsync(ctx); + return await runBuilderWithHooksAsync(ctx, buildAsync); + } else if (ctx.job.mode === BuildMode.RESIGN) { + throw new Error('Not implemented'); + } else if (ctx.job.mode === BuildMode.CUSTOM || ctx.job.mode === BuildMode.REPACK) { + return await runCustomBuildAsync(ctx); + } else { + throw new Error('Not implemented'); + } +} + +async function buildAsync(ctx: BuildContext): Promise { + await setupAsync(ctx); + const evictUsedBefore = new Date(); + const workingDirectory = ctx.getReactNativeProjectDirectory(); + const hasNativeCode = ctx.job.type === Workflow.GENERIC; + + if (hasNativeCode) { + await ctx.runBuildPhase(BuildPhase.FIX_GRADLEW, async () => { + await ensureLFLineEndingsInGradlewScript(ctx); + }); + } + + await ctx.runBuildPhase(BuildPhase.PREBUILD, async () => { + if (hasNativeCode) { + ctx.markBuildPhaseSkipped(); + ctx.logger.info( + 'Skipped running "expo prebuild" because the "android" directory already exists. Learn more about the build process: https://docs.expo.dev/build-reference/android-builds/' + ); + return; + } + await prebuildAsync(ctx, { + logger: ctx.logger, + workingDir: ctx.getReactNativeProjectDirectory(), + }); + }); + + await ctx.runBuildPhase(BuildPhase.RESTORE_CACHE, async () => { + if (ctx.isLocal) { + ctx.logger.info('Local builds do not support restoring cache'); + return; + } + await ctx.cacheManager?.restoreCache(ctx); + await restoreCcacheAsync({ + logger: ctx.logger, + workingDirectory, + platform: ctx.job.platform, + env: ctx.env, + secrets: ctx.job.secrets, + }); + }); + + await ctx.runBuildPhase(BuildPhase.POST_INSTALL_HOOK, async () => { + await runHookIfPresent(ctx, Hook.POST_INSTALL); + }); + + const resolvedExpoUpdatesRuntimeVersion = await ctx.runBuildPhase( + BuildPhase.CALCULATE_EXPO_UPDATES_RUNTIME_VERSION, + async () => { + return await resolveRuntimeVersionForExpoUpdatesIfConfiguredAsync({ + cwd: ctx.getReactNativeProjectDirectory(), + logger: ctx.logger, + appConfig: ctx.appConfig, + platform: ctx.job.platform, + workflow: ctx.job.type, + env: ctx.env, + }); + } + ); + + if ( + nullthrows(ctx.job.secrets, 'Secrets must be defined for non-custom builds').buildCredentials + ) { + await ctx.runBuildPhase(BuildPhase.PREPARE_CREDENTIALS, async () => { + await restoreCredentials(ctx); + await configureBuildGradle(ctx); + }); + } + await ctx.runBuildPhase(BuildPhase.CONFIGURE_EXPO_UPDATES, async () => { + await configureExpoUpdatesIfInstalledAsync(ctx, { + resolvedRuntimeVersion: resolvedExpoUpdatesRuntimeVersion?.runtimeVersion ?? null, + resolvedFingerprintSources: resolvedExpoUpdatesRuntimeVersion?.fingerprintSources ?? null, + }); + }); + + if (ctx.skipNativeBuild) { + throw new SkipNativeBuildError('Skipping Gradle build'); + } + + if (!ctx.env.EAS_BUILD_DISABLE_BUNDLE_JAVASCRIPT_STEP && shouldUseEagerBundle(ctx.metadata)) { + await ctx.runBuildPhase(BuildPhase.EAGER_BUNDLE, async () => { + await eagerBundleAsync({ + platform: ctx.job.platform, + workingDir: ctx.getReactNativeProjectDirectory(), + logger: ctx.logger, + env: { + ...ctx.env, + ...(resolvedExpoUpdatesRuntimeVersion?.runtimeVersion + ? { + EXPO_UPDATES_FINGERPRINT_OVERRIDE: + resolvedExpoUpdatesRuntimeVersion?.runtimeVersion, + EXPO_UPDATES_WORKFLOW_OVERRIDE: ctx.job.type, + } + : null), + }, + packageManager: ctx.packageManager, + }); + }); + } + + await ctx.runBuildPhase(BuildPhase.RUN_GRADLEW, async () => { + const gradleCommand = resolveGradleCommand(ctx.job); + await runGradleCommand(ctx, { + logger: ctx.logger, + gradleCommand, + androidDir: path.join(ctx.getReactNativeProjectDirectory(), 'android'), + ...(resolvedExpoUpdatesRuntimeVersion?.runtimeVersion + ? { + extraEnv: { + EXPO_UPDATES_FINGERPRINT_OVERRIDE: resolvedExpoUpdatesRuntimeVersion.runtimeVersion, + EXPO_UPDATES_WORKFLOW_OVERRIDE: ctx.job.type, + }, + } + : null), + }); + }); + + await ctx.runBuildPhase(BuildPhase.PRE_UPLOAD_ARTIFACTS_HOOK, async () => { + await runHookIfPresent(ctx, Hook.PRE_UPLOAD_ARTIFACTS); + }); + + await ctx.runBuildPhase(BuildPhase.UPLOAD_APPLICATION_ARCHIVE, async () => { + await uploadApplicationArchive(ctx, { + patternOrPath: ctx.job.applicationArchivePath ?? 'android/app/build/outputs/**/*.{apk,aab}', + rootDir: ctx.getReactNativeProjectDirectory(), + logger: ctx.logger, + }); + }); + + await ctx.runBuildPhase(BuildPhase.SAVE_CACHE, async () => { + if (ctx.isLocal) { + ctx.logger.info('Local builds do not support saving cache.'); + return; + } + await ctx.cacheManager?.saveCache(ctx); + await saveCcacheAsync({ + logger: ctx.logger, + workingDirectory, + platform: ctx.job.platform, + evictUsedBefore, + env: ctx.env, + secrets: ctx.job.secrets, + }); + }); + + await ctx.runBuildPhase(BuildPhase.CACHE_STATS, async () => { + await cacheStatsAsync({ + logger: ctx.logger, + env: ctx.env, + }); + }); +} diff --git a/packages/build-tools/src/builders/common.ts b/packages/build-tools/src/builders/common.ts new file mode 100644 index 0000000000..d93234bc54 --- /dev/null +++ b/packages/build-tools/src/builders/common.ts @@ -0,0 +1,52 @@ +import { BuildJob, BuildPhase, Ios, Platform } from '@expo/eas-build-job'; + +import { Artifacts, BuildContext } from '../context'; +import { findAndUploadXcodeBuildLogsAsync } from '../ios/xcodeBuildLogs'; +import { maybeFindAndUploadBuildArtifacts } from '../utils/artifacts'; +import { Hook, runHookIfPresent } from '../utils/hooks'; + +export async function runBuilderWithHooksAsync( + ctx: BuildContext, + builderAsync: (ctx: BuildContext) => Promise +): Promise { + try { + let buildSuccess = true; + try { + await builderAsync(ctx); + await ctx.runBuildPhase(BuildPhase.ON_BUILD_SUCCESS_HOOK, async () => { + await runHookIfPresent(ctx, Hook.ON_BUILD_SUCCESS); + }); + } catch (err: any) { + buildSuccess = false; + await ctx.runBuildPhase(BuildPhase.ON_BUILD_ERROR_HOOK, async () => { + await runHookIfPresent(ctx, Hook.ON_BUILD_ERROR); + }); + throw err; + } finally { + await ctx.runBuildPhase(BuildPhase.ON_BUILD_COMPLETE_HOOK, async () => { + await runHookIfPresent(ctx, Hook.ON_BUILD_COMPLETE, { + extraEnvs: { + EAS_BUILD_STATUS: buildSuccess ? 'finished' : 'errored', + }, + }); + }); + + if (ctx.job.platform === Platform.IOS) { + await findAndUploadXcodeBuildLogsAsync(ctx as BuildContext, { + logger: ctx.logger, + }); + } + + await ctx.runBuildPhase(BuildPhase.UPLOAD_BUILD_ARTIFACTS, async () => { + await maybeFindAndUploadBuildArtifacts(ctx, { + logger: ctx.logger, + }); + }); + } + } catch (err: any) { + err.artifacts = ctx.artifacts; + throw err; + } + + return ctx.artifacts; +} diff --git a/packages/build-tools/src/builders/custom.ts b/packages/build-tools/src/builders/custom.ts new file mode 100644 index 0000000000..b4a8485b19 --- /dev/null +++ b/packages/build-tools/src/builders/custom.ts @@ -0,0 +1,105 @@ +import assert from 'assert'; +import path from 'path'; +import fs from 'fs/promises'; + +import nullthrows from 'nullthrows'; +import { BuildJob, BuildPhase, BuildTrigger, Ios, Platform } from '@expo/eas-build-job'; +import { BuildConfigParser, BuildStepGlobalContext, StepsConfigParser, errors } from '@expo/steps'; + +import { Artifacts, BuildContext } from '../context'; +import { prepareProjectSourcesAsync } from '../common/projectSources'; +import { getEasFunctions } from '../steps/easFunctions'; +import { CustomBuildContext } from '../customBuildContext'; +import { resolveEnvFromBuildProfileAsync } from '../common/easBuildInternal'; +import { getEasFunctionGroups } from '../steps/easFunctionGroups'; +import { findAndUploadXcodeBuildLogsAsync } from '../ios/xcodeBuildLogs'; +import { retryAsync } from '../utils/retry'; + +export async function runCustomBuildAsync(ctx: BuildContext): Promise { + const customBuildCtx = new CustomBuildContext(ctx); + + await ctx.runBuildPhase(BuildPhase.PREPARE_PROJECT, async () => { + await retryAsync( + async () => { + await fs.rm(customBuildCtx.projectSourceDirectory, { recursive: true, force: true }); + await fs.mkdir(customBuildCtx.projectSourceDirectory, { recursive: true }); + + await prepareProjectSourcesAsync(ctx, customBuildCtx.projectSourceDirectory); + }, + { + retryOptions: { + retries: 3, + retryIntervalMs: 1_000, + }, + } + ); + }); + + if (ctx.job.triggeredBy === BuildTrigger.GIT_BASED_INTEGRATION) { + // We need to setup envs from eas.json + const env = await resolveEnvFromBuildProfileAsync(ctx, { + cwd: path.join(customBuildCtx.projectSourceDirectory, ctx.job.projectRootDirectory ?? '.'), + }); + ctx.updateEnv(env); + customBuildCtx.updateEnv(ctx.env); + } + + assert( + 'steps' in ctx.job || 'customBuildConfig' in ctx.job, + 'Steps or custom build config path are required in custom jobs' + ); + + const globalContext = new BuildStepGlobalContext(customBuildCtx, false); + const easFunctions = getEasFunctions(customBuildCtx); + const easFunctionGroups = getEasFunctionGroups(customBuildCtx); + const parser = ctx.job.steps + ? new StepsConfigParser(globalContext, { + externalFunctions: easFunctions, + externalFunctionGroups: easFunctionGroups, + steps: ctx.job.steps, + }) + : new BuildConfigParser(globalContext, { + externalFunctions: easFunctions, + externalFunctionGroups: easFunctionGroups, + configPath: path.join( + ctx.getReactNativeProjectDirectory(customBuildCtx.projectSourceDirectory), + nullthrows( + ctx.job.customBuildConfig?.path, + 'Steps or custom build config path are required in custom jobs' + ) + ), + }); + const workflow = await ctx.runBuildPhase(BuildPhase.PARSE_CUSTOM_WORKFLOW_CONFIG, async () => { + try { + return await parser.parseAsync(); + } catch (parseError: any) { + ctx.logger.error('Failed to parse the custom build config file.'); + if (parseError instanceof errors.BuildWorkflowError) { + for (const err of parseError.errors) { + ctx.logger.error({ err }); + } + } + throw parseError; + } + }); + try { + try { + await workflow.executeAsync(); + } finally { + if (!ctx.artifacts.XCODE_BUILD_LOGS && ctx.job.platform === Platform.IOS) { + try { + await findAndUploadXcodeBuildLogsAsync(ctx as BuildContext, { + logger: ctx.logger, + }); + } catch { + // do nothing, it's a non-breaking error. + } + } + } + } catch (err: any) { + err.artifacts = ctx.artifacts; + throw err; + } + + return ctx.artifacts; +} diff --git a/packages/build-tools/src/builders/index.ts b/packages/build-tools/src/builders/index.ts new file mode 100644 index 0000000000..8795cb3a66 --- /dev/null +++ b/packages/build-tools/src/builders/index.ts @@ -0,0 +1,4 @@ +import androidBuilder from './android'; +import iosBuilder from './ios'; + +export { androidBuilder, iosBuilder }; diff --git a/packages/build-tools/src/builders/ios.ts b/packages/build-tools/src/builders/ios.ts new file mode 100644 index 0000000000..7cd6da0ad2 --- /dev/null +++ b/packages/build-tools/src/builders/ios.ts @@ -0,0 +1,328 @@ +import plist from '@expo/plist'; +import { IOSConfig } from '@expo/config-plugins'; +import { ManagedArtifactType, BuildMode, BuildPhase, Ios, Workflow } from '@expo/eas-build-job'; +import fs from 'fs-extra'; +import nullthrows from 'nullthrows'; + +import { Artifacts, BuildContext } from '../context'; +import { + resolveRuntimeVersionForExpoUpdatesIfConfiguredAsync, + configureExpoUpdatesIfInstalledAsync, +} from '../utils/expoUpdates'; +import { uploadApplicationArchive } from '../utils/artifacts'; +import { Hook, runHookIfPresent } from '../utils/hooks'; +import { configureXcodeProject } from '../ios/configure'; +import CredentialsManager from '../ios/credentials/manager'; +import { runFastlaneGym, runFastlaneResign } from '../ios/fastlane'; +import { installPods } from '../ios/pod'; +import { downloadApplicationArchiveAsync } from '../ios/resign'; +import { resolveArtifactPath, resolveBuildConfiguration, resolveScheme } from '../ios/resolve'; +import { setupAsync } from '../common/setup'; +import { prebuildAsync } from '../common/prebuild'; +import { prepareExecutableAsync } from '../utils/prepareBuildExecutable'; +import { getParentAndDescendantProcessPidsAsync } from '../utils/processes'; +import { eagerBundleAsync, shouldUseEagerBundle } from '../common/eagerBundle'; +import { saveCcacheAsync } from '../steps/functions/saveBuildCache'; +import { cacheStatsAsync, restoreCcacheAsync } from '../steps/functions/restoreBuildCache'; + +import { runBuilderWithHooksAsync } from './common'; +import { runCustomBuildAsync } from './custom'; + +const INSTALL_PODS_WARN_TIMEOUT_MS = 15 * 60 * 1000; // 15 minutes +const INSTALL_PODS_KILL_TIMEOUT_MS = 30 * 60 * 1000; // 30 minutes + +class InstallPodsTimeoutError extends Error {} + +export default async function iosBuilder(ctx: BuildContext): Promise { + if (ctx.job.mode === BuildMode.BUILD) { + await prepareExecutableAsync(ctx); + return await runBuilderWithHooksAsync(ctx, buildAsync); + } else if (ctx.job.mode === BuildMode.RESIGN) { + return await resignAsync(ctx); + } else if (ctx.job.mode === BuildMode.CUSTOM || ctx.job.mode === BuildMode.REPACK) { + return await runCustomBuildAsync(ctx); + } else { + throw new Error('Not implemented'); + } +} + +async function buildAsync(ctx: BuildContext): Promise { + await setupAsync(ctx); + const hasNativeCode = ctx.job.type === Workflow.GENERIC; + const evictUsedBefore = new Date(); + const credentialsManager = new CredentialsManager(ctx); + const workingDirectory = ctx.getReactNativeProjectDirectory(); + try { + const credentials = await ctx.runBuildPhase(BuildPhase.PREPARE_CREDENTIALS, async () => { + return await credentialsManager.prepare(); + }); + + await ctx.runBuildPhase(BuildPhase.PREBUILD, async () => { + if (hasNativeCode) { + ctx.markBuildPhaseSkipped(); + ctx.logger.info( + 'Skipped running "expo prebuild" because the "ios" directory already exists. Learn more about the build process: https://docs.expo.dev/build-reference/ios-builds/' + ); + return; + } + const extraEnvs: Record = credentials?.teamId + ? { APPLE_TEAM_ID: credentials.teamId } + : {}; + await prebuildAsync(ctx, { + logger: ctx.logger, + workingDir: ctx.getReactNativeProjectDirectory(), + options: { extraEnvs }, + }); + }); + + await ctx.runBuildPhase(BuildPhase.RESTORE_CACHE, async () => { + if (ctx.isLocal) { + ctx.logger.info('Local builds do not support restoring cache'); + return; + } + await ctx.cacheManager?.restoreCache(ctx); + await restoreCcacheAsync({ + logger: ctx.logger, + workingDirectory, + platform: ctx.job.platform, + env: ctx.env, + secrets: ctx.job.secrets, + }); + }); + + await ctx.runBuildPhase(BuildPhase.INSTALL_PODS, async () => { + await runInstallPodsAsync(ctx); + }); + + await ctx.runBuildPhase(BuildPhase.POST_INSTALL_HOOK, async () => { + await runHookIfPresent(ctx, Hook.POST_INSTALL); + }); + + const resolvedExpoUpdatesRuntimeVersion = await ctx.runBuildPhase( + BuildPhase.CALCULATE_EXPO_UPDATES_RUNTIME_VERSION, + async () => { + return await resolveRuntimeVersionForExpoUpdatesIfConfiguredAsync({ + cwd: ctx.getReactNativeProjectDirectory(), + logger: ctx.logger, + appConfig: ctx.appConfig, + platform: ctx.job.platform, + workflow: ctx.job.type, + env: ctx.env, + }); + } + ); + + const buildConfiguration = resolveBuildConfiguration(ctx); + if (credentials) { + await ctx.runBuildPhase(BuildPhase.CONFIGURE_XCODE_PROJECT, async () => { + await configureXcodeProject(ctx, { credentials, buildConfiguration }); + }); + } + + await ctx.runBuildPhase(BuildPhase.CONFIGURE_EXPO_UPDATES, async () => { + await configureExpoUpdatesIfInstalledAsync(ctx, { + resolvedRuntimeVersion: resolvedExpoUpdatesRuntimeVersion?.runtimeVersion ?? null, + resolvedFingerprintSources: resolvedExpoUpdatesRuntimeVersion?.fingerprintSources ?? null, + }); + }); + + if (!ctx.env.EAS_BUILD_DISABLE_BUNDLE_JAVASCRIPT_STEP && shouldUseEagerBundle(ctx.metadata)) { + await ctx.runBuildPhase(BuildPhase.EAGER_BUNDLE, async () => { + await eagerBundleAsync({ + platform: ctx.job.platform, + workingDir: ctx.getReactNativeProjectDirectory(), + logger: ctx.logger, + env: { + ...ctx.env, + ...(resolvedExpoUpdatesRuntimeVersion?.runtimeVersion + ? { + EXPO_UPDATES_FINGERPRINT_OVERRIDE: + resolvedExpoUpdatesRuntimeVersion?.runtimeVersion, + EXPO_UPDATES_WORKFLOW_OVERRIDE: ctx.job.type, + } + : null), + }, + packageManager: ctx.packageManager, + }); + }); + } + + await ctx.runBuildPhase(BuildPhase.RUN_FASTLANE, async () => { + const scheme = resolveScheme(ctx); + const entitlements = await readEntitlementsAsync(ctx, { scheme, buildConfiguration }); + await runFastlaneGym(ctx, { + credentials, + scheme, + buildConfiguration, + entitlements, + ...(resolvedExpoUpdatesRuntimeVersion?.runtimeVersion + ? { + extraEnv: { + EXPO_UPDATES_FINGERPRINT_OVERRIDE: + resolvedExpoUpdatesRuntimeVersion?.runtimeVersion, + EXPO_UPDATES_WORKFLOW_OVERRIDE: ctx.job.type, + }, + } + : null), + }); + }); + } finally { + await ctx.runBuildPhase(BuildPhase.CLEAN_UP_CREDENTIALS, async () => { + await credentialsManager.cleanUp(); + }); + } + + await ctx.runBuildPhase(BuildPhase.PRE_UPLOAD_ARTIFACTS_HOOK, async () => { + await runHookIfPresent(ctx, Hook.PRE_UPLOAD_ARTIFACTS); + }); + + await ctx.runBuildPhase(BuildPhase.UPLOAD_APPLICATION_ARCHIVE, async () => { + await uploadApplicationArchive(ctx, { + patternOrPath: resolveArtifactPath(ctx), + rootDir: ctx.getReactNativeProjectDirectory(), + logger: ctx.logger, + }); + }); + + await ctx.runBuildPhase(BuildPhase.SAVE_CACHE, async () => { + if (ctx.isLocal) { + ctx.logger.info('Local builds do not support saving cache.'); + return; + } + await ctx.cacheManager?.saveCache(ctx); + await saveCcacheAsync({ + logger: ctx.logger, + workingDirectory, + platform: ctx.job.platform, + evictUsedBefore, + env: ctx.env, + secrets: ctx.job.secrets, + }); + }); + + await ctx.runBuildPhase(BuildPhase.CACHE_STATS, async () => { + await cacheStatsAsync({ + logger: ctx.logger, + env: ctx.env, + }); + }); +} + +async function readEntitlementsAsync( + ctx: BuildContext, + { scheme, buildConfiguration }: { scheme: string; buildConfiguration: string } +): Promise { + try { + const applicationTargetName = + await IOSConfig.BuildScheme.getApplicationTargetNameForSchemeAsync( + ctx.getReactNativeProjectDirectory(), + scheme + ); + const entitlementsPath = IOSConfig.Entitlements.getEntitlementsPath( + ctx.getReactNativeProjectDirectory(), + { + buildConfiguration, + targetName: applicationTargetName, + } + ); + if (!entitlementsPath) { + return null; + } + const entitlementsRaw = await fs.readFile(entitlementsPath, 'utf8'); + return plist.parse(entitlementsRaw); + } catch (err) { + ctx.logger.warn({ err }, 'Failed to read entitlements'); + ctx.markBuildPhaseHasWarnings(); + return null; + } +} + +async function resignAsync(ctx: BuildContext): Promise { + const applicationArchivePath = await ctx.runBuildPhase( + BuildPhase.DOWNLOAD_APPLICATION_ARCHIVE, + async () => { + return await downloadApplicationArchiveAsync(ctx); + } + ); + + const credentialsManager = new CredentialsManager(ctx); + try { + const credentials = await ctx.runBuildPhase(BuildPhase.PREPARE_CREDENTIALS, async () => { + return await credentialsManager.prepare(); + }); + + await ctx.runBuildPhase(BuildPhase.RUN_FASTLANE, async () => { + await runFastlaneResign(ctx, { + credentials: nullthrows(credentials), + ipaPath: applicationArchivePath, + }); + }); + } finally { + await ctx.runBuildPhase(BuildPhase.CLEAN_UP_CREDENTIALS, async () => { + await credentialsManager.cleanUp(); + }); + } + + await ctx.runBuildPhase(BuildPhase.UPLOAD_APPLICATION_ARCHIVE, async () => { + ctx.logger.info(`Application archive: ${applicationArchivePath}`); + await ctx.uploadArtifact({ + artifact: { + type: ManagedArtifactType.APPLICATION_ARCHIVE, + paths: [applicationArchivePath], + }, + logger: ctx.logger, + }); + }); + + return ctx.artifacts; +} + +async function runInstallPodsAsync(ctx: BuildContext): Promise { + let warnTimeout: NodeJS.Timeout | undefined; + let killTimeout: NodeJS.Timeout | undefined; + let timedOutToKill: boolean = false; + try { + const installPodsSpawnPromise = ( + await installPods(ctx, { + infoCallbackFn: () => { + warnTimeout?.refresh(); + killTimeout?.refresh(); + }, + }) + ).spawnPromise; + warnTimeout = setTimeout(() => { + ctx.logger.warn( + '"Install pods" phase takes longer then expected and it did not produce any logs in the past 15 minutes' + ); + }, INSTALL_PODS_WARN_TIMEOUT_MS); + + killTimeout = setTimeout(async () => { + timedOutToKill = true; + ctx.logger.error( + '"Install pods" phase takes a very long time and it did not produce any logs in the past 30 minutes. Most likely an unexpected error happened which caused the process to hang and it will be terminated' + ); + const ppid = nullthrows(installPodsSpawnPromise.child.pid); + const pids = await getParentAndDescendantProcessPidsAsync(ppid); + pids.forEach((pid) => { + process.kill(pid); + }); + ctx.reportError?.('"Install pods" phase takes a very long time', undefined, { + extras: { buildId: ctx.env.EAS_BUILD_ID }, + }); + }, INSTALL_PODS_KILL_TIMEOUT_MS); + + await installPodsSpawnPromise; + } catch (err: any) { + if (timedOutToKill) { + throw new InstallPodsTimeoutError('"Install pods" phase was inactive for over 30 minutes'); + } + throw err; + } finally { + if (warnTimeout) { + clearTimeout(warnTimeout); + } + if (killTimeout) { + clearTimeout(killTimeout); + } + } +} diff --git a/packages/build-tools/src/common/__tests__/projectSources.test.ts b/packages/build-tools/src/common/__tests__/projectSources.test.ts new file mode 100644 index 0000000000..724ced6c80 --- /dev/null +++ b/packages/build-tools/src/common/__tests__/projectSources.test.ts @@ -0,0 +1,576 @@ +import { randomBytes, randomUUID } from 'crypto'; +import { setTimeout } from 'timers/promises'; + +import { + ArchiveSourceType, + BuildMode, + BuildTrigger, + Job, + Platform, + Workflow, +} from '@expo/eas-build-job'; +import fetch, { Response } from 'node-fetch'; +import { vol } from 'memfs'; + +import { BuildContext } from '../../context'; +import { createMockLogger } from '../../__tests__/utils/logger'; +import { prepareProjectSourcesAsync } from '../projectSources'; +import { shallowCloneRepositoryAsync } from '../git'; + +jest.mock('@expo/turtle-spawn'); +jest.mock('node-fetch'); +jest.mock('../git'); +jest.mock('@expo/downloader'); +jest.mock('@urql/core'); + +describe('projectSources', () => { + it('should use the refreshed repository URL', async () => { + const robotAccessToken = randomUUID(); + const buildId = randomUUID(); + await vol.promises.mkdir('/workingdir/environment-secrets/', { recursive: true }); + + const gitCommitHash = randomBytes(20).toString('hex'); + + const ctx = new BuildContext( + { + triggeredBy: BuildTrigger.GIT_BASED_INTEGRATION, + type: Workflow.MANAGED, + mode: BuildMode.BUILD, + initiatingUserId: randomUUID(), + appId: randomUUID(), + projectArchive: { + type: ArchiveSourceType.GIT, + repositoryUrl: 'https://x-access-token:1234567890@github.com/expo/eas-cli.git', + gitRef: 'refs/heads/main', + gitCommitHash, + }, + platform: Platform.IOS, + secrets: { + robotAccessToken, + environmentSecrets: [], + }, + } as Job, + { + env: { + __API_SERVER_URL: 'https://api.expo.dev', + EXPO_TOKEN: robotAccessToken, + EAS_BUILD_ID: buildId, + EAS_BUILD_RUNNER: 'eas-build', + }, + workingdir: '/workingdir', + logger: createMockLogger(), + logBuffer: { getLogs: () => [], getPhaseLogs: () => [] }, + uploadArtifact: jest.fn(), + } + ); + const fetchMock = jest.mocked(fetch); + fetchMock.mockImplementation( + async () => + ({ + ok: true, + json: async () => ({ + data: { + gitRef: 'refs/heads/main', + gitCommitHash, + repositoryUrl: 'https://x-access-token:qwerty@github.com/expo/eas-cli.git', + type: ArchiveSourceType.GIT, + }, + }), + }) as Response + ); + + await prepareProjectSourcesAsync(ctx, ctx.buildDirectory); + expect(shallowCloneRepositoryAsync).toHaveBeenCalledWith( + expect.objectContaining({ + archiveSource: { + ...ctx.job.projectArchive, + repositoryUrl: 'https://x-access-token:qwerty@github.com/expo/eas-cli.git', + }, + }) + ); + }); + + it('should fallback to the original repository URL if the refresh fails', async () => { + const robotAccessToken = randomUUID(); + const buildId = randomUUID(); + await vol.promises.mkdir('/workingdir/environment-secrets/', { recursive: true }); + + const ctx = new BuildContext( + { + triggeredBy: BuildTrigger.GIT_BASED_INTEGRATION, + type: Workflow.MANAGED, + mode: BuildMode.BUILD, + initiatingUserId: randomUUID(), + appId: randomUUID(), + projectArchive: { + type: ArchiveSourceType.GIT, + repositoryUrl: 'https://x-access-token:1234567890@github.com/expo/eas-cli.git', + gitRef: 'refs/heads/main', + gitCommitHash: randomBytes(20).toString('hex'), + }, + platform: Platform.IOS, + secrets: { + robotAccessToken, + environmentSecrets: [], + }, + } as Job, + { + env: { + __API_SERVER_URL: 'https://api.expo.dev', + EXPO_TOKEN: robotAccessToken, + EAS_BUILD_ID: buildId, + EAS_BUILD_RUNNER: 'eas-build', + }, + workingdir: '/workingdir', + logger: createMockLogger(), + logBuffer: { getLogs: () => [], getPhaseLogs: () => [] }, + uploadArtifact: jest.fn(), + } + ); + const fetchMock = jest.mocked(fetch); + fetchMock.mockImplementation( + async () => + ({ + ok: false, + text: async () => 'Failed to generate repository URL', + }) as Response + ); + + await prepareProjectSourcesAsync(ctx, ctx.buildDirectory); + expect(shallowCloneRepositoryAsync).toHaveBeenLastCalledWith( + expect.objectContaining({ + archiveSource: { + ...ctx.job.projectArchive, + repositoryUrl: 'https://x-access-token:1234567890@github.com/expo/eas-cli.git', + }, + }) + ); + fetchMock.mockImplementation( + async () => + ({ + ok: false, + json: async () => ({ + // repositoryUrl is the right key + repository_url: 'https://x-access-token:qwerty@github.com/expo/eas-cli.git', + }), + }) as Response + ); + + await prepareProjectSourcesAsync(ctx, ctx.buildDirectory); + expect(shallowCloneRepositoryAsync).toHaveBeenLastCalledWith( + expect.objectContaining({ + archiveSource: { + ...ctx.job.projectArchive, + repositoryUrl: 'https://x-access-token:1234567890@github.com/expo/eas-cli.git', + }, + }) + ); + + fetchMock.mockImplementation( + async () => + ({ + ok: false, + json: () => Promise.reject(new Error('Failed to generate repository URL')), + }) as Response + ); + + await prepareProjectSourcesAsync(ctx, ctx.buildDirectory); + expect(shallowCloneRepositoryAsync).toHaveBeenLastCalledWith( + expect.objectContaining({ + archiveSource: { + ...ctx.job.projectArchive, + repositoryUrl: 'https://x-access-token:1234567890@github.com/expo/eas-cli.git', + }, + }) + ); + + expect(shallowCloneRepositoryAsync).toHaveBeenCalledTimes(3); + }, 15_000); + + it('should retry fetching the repository URL', async () => { + const robotAccessToken = randomUUID(); + const buildId = randomUUID(); + await vol.promises.mkdir('/workingdir/environment-secrets/', { recursive: true }); + + const gitCommitHash = randomBytes(20).toString('hex'); + + const ctx = new BuildContext( + { + triggeredBy: BuildTrigger.GIT_BASED_INTEGRATION, + type: Workflow.MANAGED, + mode: BuildMode.BUILD, + initiatingUserId: randomUUID(), + appId: randomUUID(), + projectArchive: { + type: ArchiveSourceType.GIT, + repositoryUrl: 'https://x-access-token:1234567890@github.com/expo/eas-cli.git', + gitRef: 'refs/heads/main', + gitCommitHash, + }, + platform: Platform.IOS, + secrets: { + robotAccessToken, + environmentSecrets: [], + }, + } as Job, + { + env: { + __API_SERVER_URL: 'https://api.expo.dev', + EXPO_TOKEN: robotAccessToken, + EAS_BUILD_ID: buildId, + EAS_BUILD_RUNNER: 'eas-build', + }, + workingdir: '/workingdir', + logger: createMockLogger(), + logBuffer: { getLogs: () => [], getPhaseLogs: () => [] }, + uploadArtifact: jest.fn(), + } + ); + const fetchMock = jest.mocked(fetch); + fetchMock.mockImplementationOnce( + async () => + ({ + ok: false, + text: async () => 'Failed to generate repository URL', + }) as Response + ); + fetchMock.mockImplementationOnce( + async () => + ({ + ok: true, + json: async () => ({ + data: { + repositoryUrl: 'https://x-access-token:qwerty@github.com/expo/eas-cli.git', + gitRef: 'refs/heads/main', + gitCommitHash, + type: ArchiveSourceType.GIT, + }, + }), + }) as Response + ); + + await prepareProjectSourcesAsync(ctx, ctx.buildDirectory); + expect(shallowCloneRepositoryAsync).toHaveBeenLastCalledWith( + expect.objectContaining({ + archiveSource: { + ...ctx.job.projectArchive, + repositoryUrl: 'https://x-access-token:qwerty@github.com/expo/eas-cli.git', + }, + }) + ); + }, 15_000); + + it(`should fallback to the original repository URL if we're missing some config`, async () => { + const robotAccessToken = randomUUID(); + await vol.promises.mkdir('/workingdir/environment-secrets/', { recursive: true }); + const logger = createMockLogger(); + + const ctx = new BuildContext( + { + triggeredBy: BuildTrigger.GIT_BASED_INTEGRATION, + type: Workflow.MANAGED, + mode: BuildMode.BUILD, + initiatingUserId: randomUUID(), + appId: randomUUID(), + projectArchive: { + type: ArchiveSourceType.GIT, + repositoryUrl: 'https://x-access-token:1234567890@github.com/expo/eas-cli.git', + gitRef: 'refs/heads/main', + gitCommitHash: randomBytes(20).toString('hex'), + }, + platform: Platform.IOS, + secrets: { + robotAccessToken, + environmentSecrets: [], + }, + } as Job, + { + env: { + __API_SERVER_URL: 'https://api.expo.dev', + EXPO_TOKEN: robotAccessToken, + EAS_BUILD_RUNNER: 'eas-build', + // EAS_BUILD_ID: buildId, + }, + workingdir: '/workingdir', + logger, + logBuffer: { getLogs: () => [], getPhaseLogs: () => [] }, + uploadArtifact: jest.fn(), + } + ); + + await prepareProjectSourcesAsync(ctx, ctx.buildDirectory); + + expect(logger.error).toHaveBeenCalledWith( + { err: expect.any(Error) }, + 'Failed to refresh project archive, falling back to the original one' + ); + }); + + describe('uploadProjectMetadataAsFireAndForget', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should upload project metadata for build job with URL archive type', async () => { + const robotAccessToken = randomUUID(); + const buildId = randomUUID(); + const bucketKey = `test-bucket-key-${randomUUID()}`; + await vol.promises.mkdir('/workingdir/build', { recursive: true }); + await vol.promises.writeFile('/workingdir/build/package.json', '{}'); + await vol.promises.writeFile('/workingdir/build/README.md', 'Hello, world!'); + + const mockGraphqlMutation = jest.fn(); + mockGraphqlMutation + .mockReturnValueOnce({ + toPromise: () => + Promise.resolve({ + data: { + uploadSession: { + createUploadSession: { + url: 'https://storage.example.com/upload', + bucketKey, + headers: { 'x-custom-header': 'value' }, + }, + }, + }, + }), + }) + .mockReturnValueOnce({ + toPromise: () => + Promise.resolve({ + data: { + build: { + updateBuildMetadata: { + id: buildId, + }, + }, + }, + }), + }); + + const ctx = new BuildContext( + { + triggeredBy: BuildTrigger.EAS_CLI, + type: Workflow.MANAGED, + mode: BuildMode.BUILD, + initiatingUserId: randomUUID(), + appId: randomUUID(), + projectArchive: { + type: ArchiveSourceType.URL, + url: 'https://example.com/project.tar.gz', + }, + platform: Platform.IOS, + secrets: { + robotAccessToken, + environmentSecrets: [], + }, + } as Job, + { + env: { + __API_SERVER_URL: 'https://api.expo.dev', + EXPO_TOKEN: robotAccessToken, + EAS_BUILD_ID: buildId, + EAS_BUILD_RUNNER: 'eas-build', + }, + workingdir: '/workingdir', + logger: createMockLogger(), + logBuffer: { getLogs: () => [], getPhaseLogs: () => [] }, + uploadArtifact: jest.fn(), + } + ); + + ctx.graphqlClient.mutation = mockGraphqlMutation as any; + + const fetchMock = jest.mocked(fetch); + fetchMock.mockImplementation(async () => ({ ok: true }) as Response); + + // Call prepareProjectSourcesAsync and don't await metadata upload + await prepareProjectSourcesAsync(ctx, ctx.buildDirectory); + + // Wait for the fire-and-forget async operation to complete + await setTimeout(1000); + + // Verify the upload session was created + expect(mockGraphqlMutation).toHaveBeenNthCalledWith( + 1, + expect.anything(), + expect.objectContaining({}) + ); + + // Verify metadata was uploaded to storage + expect(fetchMock).toHaveBeenCalledWith( + 'https://storage.example.com/upload', + expect.objectContaining({ + method: 'PUT', + headers: { 'x-custom-header': 'value' }, + body: '{"archiveContent":["project/README.md","project/package.json"]}', + }) + ); + + // Verify build metadata was updated + expect(mockGraphqlMutation).toHaveBeenNthCalledWith( + 2, + expect.anything(), + expect.objectContaining({ + buildId, + projectMetadataFile: { + type: 'GCS', + bucketKey, + }, + }) + ); + }); + + it('should not await the metadata upload (fire-and-forget)', async () => { + const robotAccessToken = randomUUID(); + const buildId = randomUUID(); + await vol.promises.mkdir('/workingdir/build', { recursive: true }); + await vol.promises.writeFile('/workingdir/build/app.json', '{}'); + + let uploadCompleted = false; + const mockGraphqlMutation = jest.fn(); + mockGraphqlMutation + .mockReturnValueOnce({ + toPromise: async () => { + // Simulate a slow upload + await setTimeout(100); + uploadCompleted = true; + return { + data: { + uploadSession: { + createUploadSession: { + url: 'https://storage.example.com/upload', + bucketKey: 'test-key', + headers: {}, + }, + }, + }, + }; + }, + }) + .mockReturnValueOnce({ + toPromise: () => + Promise.resolve({ + data: { + build: { + updateBuildMetadata: { + id: buildId, + }, + }, + }, + }), + }); + + const ctx = new BuildContext( + { + triggeredBy: BuildTrigger.EAS_CLI, + type: Workflow.MANAGED, + mode: BuildMode.BUILD, + initiatingUserId: randomUUID(), + appId: randomUUID(), + projectArchive: { + type: ArchiveSourceType.URL, + url: 'https://example.com/project.tar.gz', + }, + platform: Platform.IOS, + secrets: { + robotAccessToken, + environmentSecrets: [], + }, + } as Job, + { + env: { + __API_SERVER_URL: 'https://api.expo.dev', + EXPO_TOKEN: robotAccessToken, + EAS_BUILD_ID: buildId, + EAS_BUILD_RUNNER: 'eas-build', + }, + workingdir: '/workingdir', + logger: createMockLogger(), + logBuffer: { getLogs: () => [], getPhaseLogs: () => [] }, + uploadArtifact: jest.fn(), + } + ); + + ctx.graphqlClient.mutation = mockGraphqlMutation as any; + + const fetchMock = jest.mocked(fetch); + fetchMock.mockResolvedValue({ ok: true } as Response); + + const startTime = Date.now(); + await prepareProjectSourcesAsync(ctx, ctx.buildDirectory); + const endTime = Date.now(); + + // prepareProjectSourcesAsync should complete quickly without waiting for upload + expect(endTime - startTime).toBeLessThan(50); + // Upload should not be completed yet + expect(uploadCompleted).toBe(false); + + // Wait for the fire-and-forget operation to complete + await setTimeout(150); + + // Now the upload should be completed + expect(uploadCompleted).toBe(true); + }); + + it('should handle upload errors gracefully without failing the build', async () => { + const robotAccessToken = randomUUID(); + const buildId = randomUUID(); + await vol.promises.mkdir('/workingdir/build', { recursive: true }); + await vol.promises.writeFile('/workingdir/build/index.js', 'console.log("test");'); + + const mockGraphqlMutation = jest.fn(); + mockGraphqlMutation.mockReturnValue({ + toPromise: () => Promise.reject(new Error('Upload failed')), + }); + + const logger = createMockLogger(); + const ctx = new BuildContext( + { + triggeredBy: BuildTrigger.GIT_BASED_INTEGRATION, + type: Workflow.MANAGED, + mode: BuildMode.BUILD, + initiatingUserId: randomUUID(), + appId: randomUUID(), + projectArchive: { + type: ArchiveSourceType.URL, + url: 'https://example.com/project.tar.gz', + }, + platform: Platform.IOS, + secrets: { + robotAccessToken, + environmentSecrets: [], + }, + } as Job, + { + env: { + __API_SERVER_URL: 'https://api.expo.dev', + EXPO_TOKEN: robotAccessToken, + EAS_BUILD_ID: buildId, + EAS_BUILD_RUNNER: 'eas-build', + }, + workingdir: '/workingdir', + logger, + logBuffer: { getLogs: () => [], getPhaseLogs: () => [] }, + uploadArtifact: jest.fn(), + } + ); + + ctx.graphqlClient.mutation = mockGraphqlMutation as any; + + const fetchMock = jest.mocked(fetch); + fetchMock.mockResolvedValue({ ok: true } as Response); + + // Should not throw even though upload will fail + await expect(prepareProjectSourcesAsync(ctx, ctx.buildDirectory)).resolves.not.toThrow(); + + // Wait for the fire-and-forget operation to complete + await setTimeout(100); + + // Verify that a warning was logged + expect(logger.warn).toHaveBeenCalledWith( + expect.stringContaining('Failed to upload project metadata') + ); + }); + }); +}); diff --git a/packages/build-tools/src/common/eagerBundle.ts b/packages/build-tools/src/common/eagerBundle.ts new file mode 100644 index 0000000000..a655cf1b74 --- /dev/null +++ b/packages/build-tools/src/common/eagerBundle.ts @@ -0,0 +1,38 @@ +import { Metadata, Platform } from '@expo/eas-build-job'; +import { bunyan } from '@expo/logger'; +import semver from 'semver'; + +import { runExpoCliCommand } from '../utils/project'; +import { PackageManager } from '../utils/packageManager'; + +export async function eagerBundleAsync({ + platform, + workingDir, + logger, + env, + packageManager, +}: { + platform: Platform; + workingDir: string; + logger: bunyan; + env: Record; + packageManager: PackageManager; +}): Promise { + await runExpoCliCommand({ + args: ['export:embed', '--eager', '--platform', platform, '--dev', 'false'], + options: { + cwd: workingDir, + logger, + env, + }, + packageManager, + }); +} + +export function shouldUseEagerBundle(metadata: Metadata | null | undefined): boolean { + return Boolean( + !metadata?.developmentClient && + metadata?.sdkVersion && + semver.satisfies(metadata?.sdkVersion, '>=52') + ); +} diff --git a/packages/build-tools/src/common/easBuildInternal.ts b/packages/build-tools/src/common/easBuildInternal.ts new file mode 100644 index 0000000000..4ad87b5ec7 --- /dev/null +++ b/packages/build-tools/src/common/easBuildInternal.ts @@ -0,0 +1,190 @@ +import assert from 'assert'; + +import { + BuildJob, + Env, + EasCliNpmTags, + Metadata, + sanitizeBuildJob, + sanitizeMetadata, +} from '@expo/eas-build-job'; +import { PipeMode, bunyan } from '@expo/logger'; +import spawn from '@expo/turtle-spawn'; +import Joi from 'joi'; +import nullthrows from 'nullthrows'; +import { BuildStepEnv } from '@expo/steps'; + +import { BuildContext } from '../context'; +import { isAtLeastNpm7Async } from '../utils/packageManager'; + +const EasBuildInternalResultSchema = Joi.object<{ job: object; metadata: object }>({ + job: Joi.object().unknown(), + metadata: Joi.object().unknown(), +}); + +export async function runEasBuildInternalAsync({ + job, + logger, + env, + cwd, + projectRootOverride, +}: { + job: TJob; + logger: bunyan; + env: BuildStepEnv; + cwd: string; + projectRootOverride?: string; +}): Promise<{ + newJob: TJob; + newMetadata: Metadata; +}> { + const { cmd, args, extraEnv } = await resolveEasCommandPrefixAndEnvAsync(); + const { buildProfile, githubTriggerOptions } = job; + assert(buildProfile, 'build profile is missing in a build from git-based integration.'); + + const autoSubmitArgs = []; + if (githubTriggerOptions?.submitProfile) { + autoSubmitArgs.push('--auto-submit-with-profile'); + autoSubmitArgs.push(githubTriggerOptions.submitProfile); + } else if (githubTriggerOptions?.autoSubmit) { + autoSubmitArgs.push('--auto-submit'); + } + + const result = await spawn( + cmd, + [ + ...args, + 'build:internal', + '--platform', + job.platform, + '--profile', + buildProfile, + ...autoSubmitArgs, + ], + { + cwd, + env: { + ...env, + EXPO_TOKEN: nullthrows(job.secrets, 'Secrets must be defined for non-custom builds') + .robotAccessToken, + ...extraEnv, + EAS_PROJECT_ROOT: projectRootOverride, + }, + logger, + // This prevents printing stdout with job secrets and credentials to logs. + mode: PipeMode.STDERR_ONLY_AS_STDOUT, + } + ); + + const stdout = result.stdout.toString(); + const parsed = JSON.parse(stdout); + return validateEasBuildInternalResult({ + result: parsed, + oldJob: job, + }); +} + +export async function resolveEnvFromBuildProfileAsync( + ctx: BuildContext, + { cwd }: { cwd: string } +): Promise { + const { cmd, args, extraEnv } = await resolveEasCommandPrefixAndEnvAsync(); + const { buildProfile } = ctx.job; + assert(buildProfile, 'build profile is missing in a build from git-based integration.'); + let spawnResult; + try { + spawnResult = await spawn( + cmd, + [ + ...args, + 'config', + '--platform', + ctx.job.platform, + '--profile', + buildProfile, + '--non-interactive', + '--json', + '--eas-json-only', + ], + { + cwd, + env: { ...ctx.env, ...extraEnv }, + } + ); + } catch (err: any) { + ctx.logger.error(`Failed to the read build profile ${buildProfile} from eas.json.`); + ctx.logger.error(err.stderr?.toString()); + throw Error(`Failed to read the build profile ${buildProfile} from eas.json.`); + } + const stdout = spawnResult.stdout.toString(); + const parsed = JSON.parse(stdout); + const env = validateEnvs(parsed.buildProfile); + return env; +} + +async function resolveEasCommandPrefixAndEnvAsync(): Promise<{ + cmd: string; + args: string[]; + extraEnv: Env; +}> { + const npxArgsPrefix = (await isAtLeastNpm7Async()) ? ['-y'] : []; + if (process.env.ENVIRONMENT === 'development') { + return { + cmd: 'npx', + args: [...npxArgsPrefix, `eas-cli@${EasCliNpmTags.STAGING}`], + extraEnv: {}, + }; + } else if (process.env.ENVIRONMENT === 'staging') { + return { + cmd: 'npx', + args: [...npxArgsPrefix, `eas-cli@${EasCliNpmTags.STAGING}`], + extraEnv: { EXPO_STAGING: '1' }, + }; + } else { + return { + cmd: 'npx', + args: [...npxArgsPrefix, `eas-cli@${EasCliNpmTags.PRODUCTION}`], + extraEnv: {}, + }; + } +} + +function validateEasBuildInternalResult({ + oldJob, + result, +}: { + oldJob: TJob; + result: any; +}): { newJob: TJob; newMetadata: Metadata } { + const { value, error } = EasBuildInternalResultSchema.validate(result, { + stripUnknown: true, + convert: true, + abortEarly: false, + }); + if (error) { + throw error; + } + const newJob = sanitizeBuildJob({ + ...value.job, + // We want to retain values that we have set on the job. + appId: oldJob.appId, + initiatingUserId: oldJob.initiatingUserId, + }) as TJob; + assert(newJob.platform === oldJob.platform, 'eas-cli returned a job for a wrong platform'); + const newMetadata = sanitizeMetadata(value.metadata); + return { newJob, newMetadata }; +} + +function validateEnvs(result: any): Env { + const { value, error } = Joi.object({ + env: Joi.object().pattern(Joi.string(), Joi.string()), + }).validate(result, { + stripUnknown: true, + convert: true, + abortEarly: false, + }); + if (error) { + throw error; + } + return value?.env; +} diff --git a/packages/build-tools/src/common/fastlane.ts b/packages/build-tools/src/common/fastlane.ts new file mode 100644 index 0000000000..8efccd33dc --- /dev/null +++ b/packages/build-tools/src/common/fastlane.ts @@ -0,0 +1,7 @@ +export const COMMON_FASTLANE_ENV = { + FASTLANE_DISABLE_COLORS: '1', + FASTLANE_SKIP_UPDATE_CHECK: '1', + SKIP_SLOW_FASTLANE_WARNING: 'true', + FASTLANE_HIDE_TIMESTAMP: 'true', + LC_ALL: 'en_US.UTF-8', +}; diff --git a/packages/build-tools/src/common/git.ts b/packages/build-tools/src/common/git.ts new file mode 100644 index 0000000000..23d608d844 --- /dev/null +++ b/packages/build-tools/src/common/git.ts @@ -0,0 +1,98 @@ +import { ArchiveSource, ArchiveSourceType } from '@expo/eas-build-job'; +import { bunyan } from '@expo/logger'; +import spawn from '@expo/turtle-spawn'; + +export async function shallowCloneRepositoryAsync({ + logger, + archiveSource, + destinationDirectory, +}: { + logger: bunyan; + archiveSource: ArchiveSource & { type: ArchiveSourceType.GIT }; + destinationDirectory: string; +}): Promise { + const { repositoryUrl } = archiveSource; + try { + await spawn('git', ['init'], { cwd: destinationDirectory }); + await spawn('git', ['remote', 'add', 'origin', repositoryUrl], { cwd: destinationDirectory }); + + const { gitRef, gitCommitHash } = archiveSource; + + await spawn('git', ['fetch', 'origin', '--depth', '1', '--no-tags', gitCommitHash], { + cwd: destinationDirectory, + }); + + await spawn('git', ['checkout', gitCommitHash], { cwd: destinationDirectory }); + + // If we have a gitRef, we try to add it to the repo. + if (gitRef) { + const { name, type } = getStrippedBranchOrTagName(gitRef); + switch (type) { + // If the gitRef is for a tag, we add a lightweight tag to current commit. + case 'tag': { + await spawn('git', ['tag', name], { cwd: destinationDirectory }); + break; + } + // gitRef for a branch may come as: + // - qualified ref (e.g. refs/heads/feature/add-icon), detected as "branch" for a push, + // - unqualified ref (e.g. feature/add-icon), detected as "other" for a pull request. + case 'branch': + case 'other': { + await spawn('git', ['checkout', '-b', name], { cwd: destinationDirectory }); + break; + } + } + } + } catch (err: any) { + const sanitizedUrl = getSanitizedGitUrl(repositoryUrl); + if (sanitizedUrl) { + logger.error(`Failed to clone git repository: ${sanitizedUrl}.`); + } else { + logger.error('Failed to clone git repository.'); + } + logger.error(err.stderr); + throw err; + } +} + +function getSanitizedGitUrl(maybeGitUrl: string): string | null { + try { + const url = new URL(maybeGitUrl); + if (url.password) { + url.password = '*******'; + } + return url.toString(); + } catch { + return null; + } +} + +function getStrippedBranchOrTagName(ref: string): { + name: string; + type: 'branch' | 'tag' | 'other'; +} { + const branchRegex = /(\/?refs)?\/?heads\/(.+)/; + const branchMatch = ref.match(branchRegex); + + if (branchMatch) { + return { + name: branchMatch[2], + type: 'branch', + }; + } + + const tagRegex = /(\/?refs)?\/?tags\/(.+)/; + const tagMatch = ref.match(tagRegex); + + if (tagMatch) { + return { + name: tagMatch[2], + type: 'tag', + }; + } + + return { + name: ref, + type: 'other', + }; +} diff --git a/packages/build-tools/src/common/installDependencies.ts b/packages/build-tools/src/common/installDependencies.ts new file mode 100644 index 0000000000..980c50048f --- /dev/null +++ b/packages/build-tools/src/common/installDependencies.ts @@ -0,0 +1,84 @@ +import path from 'path'; + +import { Job } from '@expo/eas-build-job'; +import spawn, { SpawnPromise, SpawnResult, SpawnOptions } from '@expo/turtle-spawn'; + +import { BuildContext } from '../context'; +import { PackageManager, findPackagerRootDir } from '../utils/packageManager'; +import { isUsingModernYarnVersion } from '../utils/project'; + +export async function installDependenciesAsync({ + packageManager, + env, + logger, + infoCallbackFn, + cwd, + useFrozenLockfile, +}: { + packageManager: PackageManager; + env: Record; + cwd: string; + logger: Exclude; + infoCallbackFn?: SpawnOptions['infoCallbackFn']; + useFrozenLockfile: boolean; +}): Promise<{ spawnPromise: SpawnPromise }> { + let args: string[]; + switch (packageManager) { + case PackageManager.NPM: { + args = useFrozenLockfile ? ['ci', '--include=dev'] : ['install']; + break; + } + case PackageManager.PNPM: { + args = ['install', useFrozenLockfile ? '--frozen-lockfile' : '--no-frozen-lockfile']; + break; + } + case PackageManager.YARN: { + const isModernYarnVersion = await isUsingModernYarnVersion(cwd); + if (isModernYarnVersion) { + if (env['EAS_YARN_FOCUS_WORKSPACE']) { + args = ['workspaces', 'focus', env['EAS_YARN_FOCUS_WORKSPACE']]; + } else { + args = [ + 'install', + '--inline-builds', + useFrozenLockfile ? '--immutable' : '--no-immutable', + ]; + } + } else { + args = ['install', ...(useFrozenLockfile ? ['--frozen-lockfile'] : [])]; + } + break; + } + case PackageManager.BUN: + args = ['install', ...(useFrozenLockfile ? ['--frozen-lockfile'] : [])]; + break; + default: + throw new Error(`Unsupported package manager: ${packageManager}`); + } + if (env['EAS_VERBOSE'] === '1') { + args = [...args, '--verbose']; + } + logger.info(`Running "${packageManager} ${args.join(' ')}" in ${cwd} directory`); + return { + spawnPromise: spawn(packageManager, args, { + cwd, + logger, + infoCallbackFn, + env, + }), + }; +} + +export function resolvePackagerDir(ctx: BuildContext): string { + const packagerRunDir = findPackagerRootDir(ctx.getReactNativeProjectDirectory()); + if (packagerRunDir !== ctx.getReactNativeProjectDirectory()) { + const relativeReactNativeProjectDirectory = path.relative( + ctx.buildDirectory, + ctx.getReactNativeProjectDirectory() + ); + ctx.logger.info( + `We detected that '${relativeReactNativeProjectDirectory}' is a ${ctx.packageManager} workspace` + ); + } + return packagerRunDir; +} diff --git a/packages/build-tools/src/common/prebuild.ts b/packages/build-tools/src/common/prebuild.ts new file mode 100644 index 0000000000..8e58bdb3c4 --- /dev/null +++ b/packages/build-tools/src/common/prebuild.ts @@ -0,0 +1,67 @@ +import { BuildJob } from '@expo/eas-build-job'; +import { SpawnOptions } from '@expo/turtle-spawn'; +import { bunyan } from '@expo/logger'; + +import { BuildContext } from '../context'; +import { runExpoCliCommand } from '../utils/project'; + +import { installDependenciesAsync, resolvePackagerDir } from './installDependencies'; + +export interface PrebuildOptions { + extraEnvs?: Record; +} + +export async function prebuildAsync( + ctx: BuildContext, + { logger, workingDir, options }: { logger: bunyan; workingDir: string; options?: PrebuildOptions } +): Promise { + const spawnOptions: SpawnOptions = { + cwd: workingDir, + logger, + env: { + EXPO_IMAGE_UTILS_NO_SHARP: '1', + ...options?.extraEnvs, + ...ctx.env, + }, + }; + + const prebuildCommandArgs = getPrebuildCommandArgs(ctx); + await runExpoCliCommand({ + args: prebuildCommandArgs, + options: spawnOptions, + packageManager: ctx.packageManager, + }); + const installDependenciesSpawnPromise = ( + await installDependenciesAsync({ + packageManager: ctx.packageManager, + env: ctx.env, + logger, + cwd: resolvePackagerDir(ctx), + // prebuild sometimes modifies package.json, so we don't want to use frozen lockfile + useFrozenLockfile: false, + }) + ).spawnPromise; + await installDependenciesSpawnPromise; +} + +function getPrebuildCommandArgs(ctx: BuildContext): string[] { + let prebuildCommand = + ctx.job.experimental?.prebuildCommand ?? `prebuild --no-install --platform ${ctx.job.platform}`; + if (!prebuildCommand.match(/(?:--platform| -p)/)) { + prebuildCommand = `${prebuildCommand} --platform ${ctx.job.platform}`; + } + const npxCommandPrefix = 'npx '; + const expoCommandPrefix = 'expo '; + const expoCliCommandPrefix = 'expo-cli '; + if (prebuildCommand.startsWith(npxCommandPrefix)) { + prebuildCommand = prebuildCommand.substring(npxCommandPrefix.length).trim(); + } + if (prebuildCommand.startsWith(expoCommandPrefix)) { + prebuildCommand = prebuildCommand.substring(expoCommandPrefix.length).trim(); + } + if (prebuildCommand.startsWith(expoCliCommandPrefix)) { + prebuildCommand = prebuildCommand.substring(expoCliCommandPrefix.length).trim(); + } + + return prebuildCommand.split(' '); +} diff --git a/packages/build-tools/src/common/projectSources.ts b/packages/build-tools/src/common/projectSources.ts new file mode 100644 index 0000000000..eee98c72b4 --- /dev/null +++ b/packages/build-tools/src/common/projectSources.ts @@ -0,0 +1,298 @@ +import path from 'path'; +import fs from 'fs/promises'; + +import spawn from '@expo/turtle-spawn'; +import fetch from 'node-fetch'; +import { ArchiveSourceType, Job, ArchiveSource, ArchiveSourceSchemaZ } from '@expo/eas-build-job'; +import { bunyan } from '@expo/logger'; +import downloadFile from '@expo/downloader'; +import { z } from 'zod'; +import { asyncResult } from '@expo/results'; +import nullthrows from 'nullthrows'; +import { graphql } from 'gql.tada'; + +import { BuildContext } from '../context'; +import { turtleFetch } from '../utils/turtleFetch'; + +import { shallowCloneRepositoryAsync } from './git'; + +export async function prepareProjectSourcesAsync( + ctx: BuildContext, + destinationDirectory: string +): // Return type required to make switch exhaustive. +Promise<{ handled: boolean }> { + let projectArchive: ArchiveSource = ctx.job.projectArchive; + if (ctx.isLocal) { + console.warn('Local build, skipping project archive refresh'); + } else { + const projectArchiveResult = await asyncResult(fetchProjectArchiveSourceAsync(ctx)); + + if (!projectArchiveResult.ok) { + ctx.logger.error( + { err: projectArchiveResult.reason }, + 'Failed to refresh project archive, falling back to the original one' + ); + } + + projectArchive = projectArchiveResult.value ?? ctx.job.projectArchive; + } + + switch (projectArchive.type) { + case ArchiveSourceType.R2: + case ArchiveSourceType.GCS: { + throw new Error('Remote project sources should be resolved earlier to URL'); + } + + case ArchiveSourceType.PATH: { + await prepareProjectSourcesLocallyAsync(ctx, projectArchive.path, destinationDirectory); // used in eas build --local + return { handled: true }; + } + + case ArchiveSourceType.NONE: { + // May be used in no-sources jobs like submission jobs. + return { handled: true }; + } + + case ArchiveSourceType.URL: { + await downloadAndUnpackProjectFromTarGzAsync(ctx, projectArchive.url, destinationDirectory); + + uploadProjectMetadataAsFireAndForget(ctx, { projectDirectory: destinationDirectory }); + + return { handled: true }; + } + + case ArchiveSourceType.GIT: { + await shallowCloneRepositoryAsync({ + logger: ctx.logger, + archiveSource: projectArchive, + destinationDirectory, + }); + + uploadProjectMetadataAsFireAndForget(ctx, { projectDirectory: destinationDirectory }); + + return { handled: true }; + } + } +} + +export async function downloadAndUnpackProjectFromTarGzAsync( + ctx: BuildContext, + projectArchiveUrl: string, + destinationDirectory: string +): Promise { + const projectTarball = path.join(ctx.workingdir, 'project.tar.gz'); + try { + await downloadFile(projectArchiveUrl, projectTarball, { retry: 3 }); + } catch (err: any) { + ctx.reportError?.('Failed to download project archive', err, { + extras: { buildId: ctx.env.EAS_BUILD_ID }, + }); + throw err; + } + + await unpackTarGzAsync({ + destination: destinationDirectory, + source: projectTarball, + logger: ctx.logger, + }); +} + +async function prepareProjectSourcesLocallyAsync( + ctx: BuildContext, + projectArchivePath: string, + destinationDirectory: string +): Promise { + const projectTarball = path.join(ctx.workingdir, 'project.tar.gz'); + await fs.copyFile(projectArchivePath, projectTarball); + + await unpackTarGzAsync({ + destination: destinationDirectory, + source: projectTarball, + logger: ctx.logger, + }); +} + +async function unpackTarGzAsync({ + logger, + source, + destination, +}: { + logger: bunyan; + source: string; + destination: string; +}): Promise { + await spawn('tar', ['-C', destination, '--strip-components', '1', '-zxf', source], { + logger, + }); +} + +function uploadProjectMetadataAsFireAndForget( + ctx: BuildContext, + { projectDirectory }: { projectDirectory: string } +): void { + void (async () => { + const uploadResult = await asyncResult(uploadProjectMetadataAsync(ctx, { projectDirectory })); + if (!uploadResult.ok) { + ctx.logger.warn(`Failed to upload project metadata: ${uploadResult.reason}`); + } + })(); +} + +async function uploadProjectMetadataAsync( + ctx: BuildContext, + { projectDirectory }: { projectDirectory: string } +): Promise { + if (!ctx.job.platform) { + // Not a build job, skip. + return; + } else if ( + ctx.job.projectArchive.type === ArchiveSourceType.GCS && + ctx.job.projectArchive.metadataLocation + ) { + // Build already has project metadata, skip. + return; + } + + const files: string[] = []; + + const directoriesToScan: { dir: string; relativePath: string }[] = [ + { dir: projectDirectory, relativePath: '' }, + ]; + + while (directoriesToScan.length > 0) { + const { dir, relativePath } = directoriesToScan.shift()!; + + if (relativePath === '.git') { + // Do not include whole `.git` directory in the archive, just that it exists. + files.push('.git/...'); + continue; + } + + const entries = await fs.readdir(dir, { withFileTypes: true }); + + for (const entry of entries) { + const fullPath = path.join(dir, entry.name); + const relativeFilePath = path.join(relativePath, entry.name); + + if (entry.isDirectory()) { + directoriesToScan.push({ dir: fullPath, relativePath: relativeFilePath }); + } else { + files.push(relativeFilePath); + } + } + } + const sortedFiles = files + .map( + // Prepend entries with "project/" + (f) => path.join('project', f) + ) + .sort(); // Sort for consistent ordering + + const result = await ctx.graphqlClient + .mutation( + graphql(` + mutation { + uploadSession { + createUploadSession(type: EAS_BUILD_GCS_PROJECT_METADATA) + } + } + `), + {} + ) + .toPromise(); + + if (result.error) { + throw result.error; + } + + const uploadSession = result.data!.uploadSession.createUploadSession as { + url: string; + bucketKey: string; + headers: Record; + }; + + await fetch(uploadSession.url, { + method: 'PUT', + body: JSON.stringify({ archiveContent: sortedFiles }), + headers: uploadSession.headers, + }); + + const updateMetadataResult = await ctx.graphqlClient + .mutation( + graphql(` + mutation UpdateTurtleBuildMetadataMutation( + $buildId: ID! + $projectMetadataFile: ProjectMetadataFileInput! + ) { + build { + updateBuildMetadata( + buildId: $buildId + metadata: { projectMetadataFile: $projectMetadataFile } + ) { + id + } + } + } + `), + { + buildId: ctx.env.EAS_BUILD_ID, + projectMetadataFile: { + type: 'GCS', + bucketKey: uploadSession.bucketKey, + }, + } + ) + .toPromise(); + + if (updateMetadataResult.error) { + throw updateMetadataResult.error; + } +} + +async function fetchProjectArchiveSourceAsync(ctx: BuildContext): Promise { + const taskId = nullthrows(ctx.env.EAS_BUILD_ID, 'EAS_BUILD_ID is not set'); + const expoApiServerURL = nullthrows(ctx.env.__API_SERVER_URL, '__API_SERVER_URL is not set'); + const robotAccessToken = nullthrows( + ctx.job.secrets?.robotAccessToken, + 'robot access token is not set' + ); + + const response = await turtleFetch( + new URL( + ctx.job.platform + ? `/v2/turtle-builds/${taskId}/download-project-archive` + : `/v2/turtle-job-runs/${taskId}/download-project-archive`, + expoApiServerURL + ).toString(), + 'POST', + { + headers: { + Authorization: `Bearer ${robotAccessToken}`, + }, + timeout: 20000, + retries: 3, + logger: ctx.logger, + } + ); + + if (!response.ok) { + const textResult = await asyncResult(response.text()); + throw new Error(`Unexpected response from server (${response.status}): ${textResult.value}`); + } + + const jsonResult = await asyncResult(response.json()); + if (!jsonResult.ok) { + throw new Error( + `Expected JSON response from server (${response.status}): ${jsonResult.reason}` + ); + } + + const dataResult = z.object({ data: ArchiveSourceSchemaZ }).safeParse(jsonResult.value); + if (!dataResult.success) { + throw new Error( + `Unexpected data from server (${response.status}): ${z.prettifyError(dataResult.error)}` + ); + } + + return dataResult.data.data; +} diff --git a/packages/build-tools/src/common/setup.ts b/packages/build-tools/src/common/setup.ts new file mode 100644 index 0000000000..c4586a4e73 --- /dev/null +++ b/packages/build-tools/src/common/setup.ts @@ -0,0 +1,284 @@ +import path from 'path'; + +import spawn, { SpawnResult } from '@expo/turtle-spawn'; +import fs from 'fs-extra'; +import { BuildJob, BuildPhase, Ios, Job, Platform } from '@expo/eas-build-job'; +import { BuildTrigger } from '@expo/eas-build-job/dist/common'; +import nullthrows from 'nullthrows'; +import { ExpoConfig } from '@expo/config'; +import { UserFacingError } from '@expo/eas-build-job/dist/errors'; + +import { BuildContext } from '../context'; +import { deleteXcodeEnvLocalIfExistsAsync } from '../ios/xcodeEnv'; +import { Hook, runHookIfPresent } from '../utils/hooks'; +import { setUpNpmrcAsync } from '../utils/npmrc'; +import { + shouldUseFrozenLockfile, + isAtLeastNpm7Async, + getPackageVersionFromPackageJson, +} from '../utils/packageManager'; +import { readPackageJson } from '../utils/project'; +import { getParentAndDescendantProcessPidsAsync } from '../utils/processes'; +import { retryAsync } from '../utils/retry'; + +import { prepareProjectSourcesAsync } from './projectSources'; +import { installDependenciesAsync, resolvePackagerDir } from './installDependencies'; +import { resolveEnvFromBuildProfileAsync, runEasBuildInternalAsync } from './easBuildInternal'; + +const MAX_EXPO_DOCTOR_TIMEOUT_MS = 30 * 1000; +const INSTALL_DEPENDENCIES_WARN_TIMEOUT_MS = 15 * 60 * 1000; +const INSTALL_DEPENDENCIES_KILL_TIMEOUT_MS = 30 * 60 * 1000; + +class DoctorTimeoutError extends Error {} +class InstallDependenciesTimeoutError extends Error {} + +export async function setupAsync(ctx: BuildContext): Promise { + await ctx.runBuildPhase(BuildPhase.PREPARE_PROJECT, async () => { + await retryAsync( + async () => { + await fs.rm(ctx.buildDirectory, { recursive: true, force: true }); + await fs.mkdir(ctx.buildDirectory, { recursive: true }); + + await prepareProjectSourcesAsync(ctx, ctx.buildDirectory); + }, + { + retryOptions: { + retries: 3, + retryIntervalMs: 1_000, + }, + } + ); + + await setUpNpmrcAsync(ctx, ctx.logger); + if (ctx.job.platform === Platform.IOS && ctx.env.EAS_BUILD_RUNNER === 'eas-build') { + await deleteXcodeEnvLocalIfExistsAsync(ctx as BuildContext); + } + if (ctx.job.triggeredBy === BuildTrigger.GIT_BASED_INTEGRATION) { + // We need to setup envs from eas.json before + // eas-build-pre-install hook is called. + const env = await resolveEnvFromBuildProfileAsync(ctx, { + cwd: ctx.getReactNativeProjectDirectory(), + }); + ctx.updateEnv(env); + } + }); + + await ctx.runBuildPhase(BuildPhase.PRE_INSTALL_HOOK, async () => { + await runHookIfPresent(ctx, Hook.PRE_INSTALL); + }); + + const packageJson = await ctx.runBuildPhase(BuildPhase.READ_PACKAGE_JSON, async () => { + ctx.logger.info('Using package.json:'); + const packageJson = readPackageJson(ctx.getReactNativeProjectDirectory()); + ctx.logger.info(JSON.stringify(packageJson, null, 2)); + return packageJson; + }); + + await ctx.runBuildPhase(BuildPhase.INSTALL_DEPENDENCIES, async () => { + const expoVersion = + ctx.metadata?.sdkVersion ?? + getPackageVersionFromPackageJson({ + packageJson, + packageName: 'expo', + }); + + const reactNativeVersion = + ctx.metadata?.reactNativeVersion ?? + getPackageVersionFromPackageJson({ + packageJson, + packageName: 'react-native', + }); + + await runInstallDependenciesAsync(ctx, { + useFrozenLockfile: shouldUseFrozenLockfile({ + env: ctx.env, + sdkVersion: expoVersion, + reactNativeVersion, + }), + }); + }); + + await ctx.runBuildPhase(BuildPhase.READ_APP_CONFIG, async () => { + const appConfig = ctx.appConfig; + ctx.logger.info('Using app configuration:'); + ctx.logger.info(JSON.stringify(appConfig, null, 2)); + await validateAppConfigAsync(ctx, appConfig); + }); + + if (ctx.job.triggeredBy === BuildTrigger.GIT_BASED_INTEGRATION) { + await ctx.runBuildPhase(BuildPhase.EAS_BUILD_INTERNAL, async () => { + if (!ctx.appConfig.ios?.bundleIdentifier && ctx.job.platform === Platform.IOS) { + throw new Error( + 'The "ios.bundleIdentifier" is required to be set in app config for builds triggered by GitHub integration. Learn more: https://docs.expo.dev/versions/latest/config/app/#bundleidentifier.' + ); + } + if (!ctx.appConfig.android?.package && ctx.job.platform === Platform.ANDROID) { + throw new Error( + 'The "android.package" is required to be set in app config for builds triggered by GitHub integration. Learn more: https://docs.expo.dev/versions/latest/config/app/#package.' + ); + } + const { newJob, newMetadata } = await runEasBuildInternalAsync({ + job: ctx.job, + env: ctx.env, + logger: ctx.logger, + cwd: ctx.getReactNativeProjectDirectory(), + projectRootOverride: ctx.env.EAS_NO_VCS ? ctx.buildDirectory : undefined, + }); + ctx.updateJobInformation(newJob, newMetadata); + }); + } + + const hasExpoPackage = !!packageJson.dependencies?.expo; + if (!ctx.env.EAS_BUILD_DISABLE_EXPO_DOCTOR_STEP && hasExpoPackage) { + await ctx.runBuildPhase(BuildPhase.RUN_EXPO_DOCTOR, async () => { + try { + await runExpoDoctor(ctx); + } catch (err) { + if (err instanceof DoctorTimeoutError) { + ctx.logger.error(err.message); + } else { + ctx.logger.error({ err }, 'Command "expo doctor" failed.'); + } + ctx.markBuildPhaseHasWarnings(); + } + }); + } +} + +async function runExpoDoctor(ctx: BuildContext): Promise { + ctx.logger.info('Running "expo doctor"'); + let timeout: NodeJS.Timeout | undefined; + let timedOut = false; + const isAtLeastNpm7 = await isAtLeastNpm7Async(); + try { + const argsPrefix = isAtLeastNpm7 ? ['-y'] : []; + const promise = spawn('npx', [...argsPrefix, 'expo-doctor'], { + cwd: ctx.getReactNativeProjectDirectory(), + logger: ctx.logger, + env: ctx.env, + }); + timeout = setTimeout(async () => { + timedOut = true; + const ppid = nullthrows(promise.child.pid); + const pids = await getParentAndDescendantProcessPidsAsync(ppid); + pids.forEach((pid) => { + process.kill(pid); + }); + ctx.reportError?.(`"expo doctor" timed out`, undefined, { + extras: { buildId: ctx.env.EAS_BUILD_ID }, + }); + }, MAX_EXPO_DOCTOR_TIMEOUT_MS); + return await promise; + } catch (err: any) { + if (timedOut) { + throw new DoctorTimeoutError('"expo doctor" timed out, skipping...'); + } + throw err; + } finally { + if (timeout) { + clearTimeout(timeout); + } + } +} + +async function runInstallDependenciesAsync( + ctx: BuildContext, + { + useFrozenLockfile, + }: { + useFrozenLockfile: boolean; + } +): Promise { + let warnTimeout: NodeJS.Timeout | undefined; + let killTimeout: NodeJS.Timeout | undefined; + let killTimedOut: boolean = false; + try { + const installDependenciesSpawnPromise = ( + await installDependenciesAsync({ + packageManager: ctx.packageManager, + env: ctx.env, + logger: ctx.logger, + infoCallbackFn: () => { + if (warnTimeout) { + warnTimeout.refresh(); + } + if (killTimeout) { + killTimeout.refresh(); + } + }, + cwd: resolvePackagerDir(ctx), + useFrozenLockfile, + }) + ).spawnPromise; + + warnTimeout = setTimeout(() => { + ctx.logger.warn( + '"Install dependencies" phase takes longer then expected and it did not produce any logs in the past 15 minutes. Consider evaluating your package.json file for possible issues with dependencies' + ); + }, INSTALL_DEPENDENCIES_WARN_TIMEOUT_MS); + + killTimeout = setTimeout(async () => { + killTimedOut = true; + ctx.logger.error( + '"Install dependencies" phase takes a very long time and it did not produce any logs in the past 30 minutes. Most likely an unexpected error happened with your dependencies which caused the process to hang and it will be terminated' + ); + const ppid = nullthrows(installDependenciesSpawnPromise.child.pid); + const pids = await getParentAndDescendantProcessPidsAsync(ppid); + pids.forEach((pid) => { + process.kill(pid); + }); + ctx.reportError?.('"Install dependencies" phase takes a very long time', undefined, { + extras: { buildId: ctx.env.EAS_BUILD_ID }, + }); + }, INSTALL_DEPENDENCIES_KILL_TIMEOUT_MS); + + await installDependenciesSpawnPromise; + } catch (err: any) { + if (killTimedOut) { + throw new InstallDependenciesTimeoutError( + '"Install dependencies" phase was inactive for over 30 minutes. Please evaluate your package.json file' + ); + } + throw err; + } finally { + if (warnTimeout) { + clearTimeout(warnTimeout); + } + if (killTimeout) { + clearTimeout(killTimeout); + } + } +} + +async function validateAppConfigAsync( + ctx: BuildContext, + appConfig: ExpoConfig +): Promise { + if ( + appConfig?.extra?.eas?.projectId && + ctx.env.EAS_BUILD_PROJECT_ID && + appConfig.extra.eas.projectId !== ctx.env.EAS_BUILD_PROJECT_ID + ) { + const isUsingDynamicConfig = + (await fs.pathExists(path.join(ctx.getReactNativeProjectDirectory(), 'app.config.ts'))) || + (await fs.pathExists(path.join(ctx.getReactNativeProjectDirectory(), 'app.config.js'))); + const isGitHubBuild = ctx.job.triggeredBy === BuildTrigger.GIT_BASED_INTEGRATION; + let extraMessage = ''; + if (isGitHubBuild && isUsingDynamicConfig) { + extraMessage = + 'Make sure you connected your GitHub repository to the correct Expo project and if you are using environment variables to switch between projects in app.config.js/app.config.ts remember to set those variables in eas.json too. '; + } else if (isGitHubBuild) { + extraMessage = 'Make sure you connected your GitHub repository to the correct Expo project. '; + } else if (isUsingDynamicConfig) { + extraMessage = + 'If you are using environment variables to switch between projects in app.config.js/app.config.ts, make sure those variables are also set inside EAS Build. You can do that using "env" field in eas.json or EAS environment variables. '; + } + throw new UserFacingError( + 'EAS_BUILD_PROJECT_ID_MISMATCH', + `The value of the "extra.eas.projectId" field (${appConfig.extra.eas.projectId}) in the app config does not match the current project id (${ctx.env.EAS_BUILD_PROJECT_ID}). ${extraMessage}Learn more: https://expo.fyi/eas-config-mismatch.` + ); + } else if (ctx.env.EAS_BUILD_PROJECT_ID && !appConfig?.extra?.eas?.projectId) { + ctx.logger.error(`The "extra.eas.projectId" field is missing from your app config.`); + ctx.markBuildPhaseHasWarnings(); + } +} diff --git a/packages/build-tools/src/context.ts b/packages/build-tools/src/context.ts new file mode 100644 index 0000000000..a40c7de3dc --- /dev/null +++ b/packages/build-tools/src/context.ts @@ -0,0 +1,411 @@ +import path from 'path'; + +import fs from 'fs-extra'; +import { + ManagedArtifactType, + BuildPhase, + BuildPhaseResult, + BuildPhaseStats, + Job, + LogMarker, + Env, + errors, + Metadata, + EnvironmentSecretType, + GenericArtifactType, + isGenericArtifact, +} from '@expo/eas-build-job'; +import { ExpoConfig } from '@expo/config'; +import { bunyan } from '@expo/logger'; +import { BuildTrigger } from '@expo/eas-build-job/dist/common'; +import { Client, fetchExchange } from '@urql/core'; + +import { PackageManager, resolvePackageManager } from './utils/packageManager'; +import { resolveBuildPhaseErrorAsync } from './buildErrors/detectError'; +import { readAppConfig } from './utils/appConfig'; +import { createTemporaryEnvironmentSecretFile } from './utils/environmentSecrets'; + +export type Artifacts = Partial>; + +export interface CacheManager { + saveCache(ctx: BuildContext): Promise; + restoreCache(ctx: BuildContext): Promise; +} + +export interface LogBuffer { + getLogs(): string[]; + getPhaseLogs(buildPhase: string): string[]; +} + +export type ArtifactToUpload = + | { + type: ManagedArtifactType; + paths: string[]; + } + | { + type: GenericArtifactType; + name: string; + paths: string[]; + }; + +export interface BuildContextOptions { + workingdir: string; + logger: bunyan; + logBuffer: LogBuffer; + env: Env; + cacheManager?: CacheManager; + uploadArtifact: (spec: { artifact: ArtifactToUpload; logger: bunyan }) => Promise< + /** Workflow Artifact ID */ + | { artifactId: string | null; filename?: never } + /** This remains from the time we relied on Launcher to rename the GCS object. */ + | { artifactId?: never; filename: string | null } + >; + reportError?: ( + msg: string, + err?: Error, + options?: { tags?: Record; extras?: Record } + ) => void; + reportBuildPhaseStats?: (stats: BuildPhaseStats) => void; + skipNativeBuild?: boolean; + metadata?: Metadata; +} + +export class SkipNativeBuildError extends Error {} + +export class BuildContext { + public readonly workingdir: string; + public logger: bunyan; + public readonly logBuffer: LogBuffer; + public readonly cacheManager?: CacheManager; + public readonly reportError?: ( + msg: string, + err?: Error, + options?: { tags?: Record; extras?: Record } + ) => void; + public readonly skipNativeBuild?: boolean; + public artifacts: Artifacts = {}; + + private readonly _isLocal: boolean; + + private _env: Env; + private _job: TJob; + private _metadata?: Metadata; + private readonly defaultLogger: bunyan; + private readonly _uploadArtifact: BuildContextOptions['uploadArtifact']; + private buildPhase?: BuildPhase; + private buildPhaseSkipped = false; + private buildPhaseHasWarnings = false; + private _appConfig?: ExpoConfig; + private readonly reportBuildPhaseStats?: (stats: BuildPhaseStats) => void; + public readonly graphqlClient: Client; + + constructor(job: TJob, options: BuildContextOptions) { + this.workingdir = options.workingdir; + this.defaultLogger = options.logger; + this.logger = this.defaultLogger; + this.logBuffer = options.logBuffer; + this.cacheManager = options.cacheManager; + this._uploadArtifact = options.uploadArtifact; + this.reportError = options.reportError; + + this._job = job; + this._metadata = options.metadata; + this.skipNativeBuild = options.skipNativeBuild; + this.reportBuildPhaseStats = options.reportBuildPhaseStats; + + const environmentSecrets = this.getEnvironmentSecrets(job); + this._env = { + ...options.env, + ...job?.builderEnvironment?.env, + ...environmentSecrets, + __EAS_BUILD_ENVS_DIR: this.buildEnvsDirectory, + }; + + this._env.PATH = this._env.PATH + ? [this.buildExecutablesDirectory, this._env.PATH].join(':') + : this.buildExecutablesDirectory; + + this._isLocal = this._env.EAS_BUILD_RUNNER !== 'eas-build'; + + this.graphqlClient = new Client({ + url: new URL('graphql', this.env.__API_SERVER_URL).toString(), + exchanges: [fetchExchange], + preferGetMethod: false, + fetchOptions: { + headers: { + Authorization: `Bearer ${job.secrets?.robotAccessToken}`, + }, + }, + }); + } + + public get job(): TJob { + return this._job; + } + public get metadata(): Metadata | undefined { + return this._metadata; + } + public get env(): Env { + return this._env; + } + public get buildDirectory(): string { + return path.join(this.workingdir, 'build'); + } + public get buildLogsDirectory(): string { + return path.join(this.workingdir, 'logs'); + } + public get isLocal(): boolean { + return this._isLocal; + } + /** + * Directory used to store executables used during regular (non-custom) builds. + */ + public get buildExecutablesDirectory(): string { + return path.join(this.workingdir, 'bin'); + } + /** + * Directory used to store env variables registered in the current build step. + * All values stored here will be available in the next build phase as env variables. + */ + public get buildEnvsDirectory(): string { + return path.join(this.workingdir, 'env'); + } + public get packageManager(): PackageManager { + return resolvePackageManager(this.getReactNativeProjectDirectory()); + } + public get appConfig(): ExpoConfig { + if (!this._appConfig) { + this._appConfig = readAppConfig({ + projectDir: this.getReactNativeProjectDirectory(), + env: this.env, + logger: this.logger, + sdkVersion: this.metadata?.sdkVersion, + }).exp; + } + return this._appConfig; + } + + public async runBuildPhase( + buildPhase: BuildPhase, + phase: () => Promise, + { + doNotMarkStart = false, + doNotMarkEnd = false, + }: { + doNotMarkStart?: boolean; + doNotMarkEnd?: boolean; + } = {} + ): Promise { + let startTimestamp = Date.now(); + try { + this.setBuildPhase(buildPhase, { doNotMarkStart }); + startTimestamp = Date.now(); + const result = await phase(); + const durationMs = Date.now() - startTimestamp; + const buildPhaseResult: BuildPhaseResult = this.buildPhaseSkipped + ? BuildPhaseResult.SKIPPED + : this.buildPhaseHasWarnings + ? BuildPhaseResult.WARNING + : BuildPhaseResult.SUCCESS; + await this.endCurrentBuildPhaseAsync({ result: buildPhaseResult, doNotMarkEnd, durationMs }); + return result; + } catch (err: any) { + const durationMs = Date.now() - startTimestamp; + const resolvedError = await this.handleBuildPhaseErrorAsync(err, buildPhase); + await this.endCurrentBuildPhaseAsync({ result: BuildPhaseResult.FAIL, durationMs }); + throw resolvedError; + } + } + + public markBuildPhaseSkipped(): void { + this.buildPhaseSkipped = true; + } + + public markBuildPhaseHasWarnings(): void { + this.buildPhaseHasWarnings = true; + } + + public async uploadArtifact({ + artifact, + logger, + }: { + artifact: ArtifactToUpload; + logger: bunyan; + }): Promise<{ artifactId: string | null }> { + const result = await this._uploadArtifact({ artifact, logger }); + if (result.filename && !isGenericArtifact(artifact)) { + this.artifacts[artifact.type] = result.filename; + } + return { artifactId: result.artifactId ?? null }; + } + + public updateEnv(env: Env): void { + if (this._job.triggeredBy !== BuildTrigger.GIT_BASED_INTEGRATION) { + throw new Error( + 'Updating environment variables is only allowed when build was triggered by a git-based integration.' + ); + } + this._env = { + ...env, + ...this._env, + __EAS_BUILD_ENVS_DIR: this.buildEnvsDirectory, + }; + this._env.PATH = this._env.PATH + ? [this.buildExecutablesDirectory, this._env.PATH].join(':') + : this.buildExecutablesDirectory; + } + + public updateJobInformation(job: TJob, metadata: Metadata): void { + if (this._job.triggeredBy !== BuildTrigger.GIT_BASED_INTEGRATION) { + throw new Error( + 'Updating job information is only allowed when build was triggered by a git-based integration.' + ); + } + this._job = { + ...this._job, + ...job, + workflowInterpolationContext: + job.workflowInterpolationContext ?? this.job.workflowInterpolationContext, + triggeredBy: this._job.triggeredBy, + secrets: { + ...this.job.secrets, + ...job.secrets, + robotAccessToken: job.secrets?.robotAccessToken ?? this.job.secrets?.robotAccessToken, + environmentSecrets: [ + // Latter secrets override former ones. + ...(this.job.secrets?.environmentSecrets ?? []), + ...(job.secrets?.environmentSecrets ?? []), + ], + }, + ...(this._job.platform ? { expoBuildUrl: this._job.expoBuildUrl } : null), + }; + this._metadata = metadata; + } + + private async handleBuildPhaseErrorAsync( + err: any, + buildPhase: BuildPhase + ): Promise { + const buildError = await resolveBuildPhaseErrorAsync( + err, + this.logBuffer.getPhaseLogs(buildPhase), + { + job: this.job, + phase: buildPhase, + env: this.env, + }, + this.buildLogsDirectory + ); + if (buildError.errorCode === errors.ErrorCode.UNKNOWN_ERROR) { + // leaving message empty, website will display err.stack which already includes err.message + this.logger.error({ err }, ''); + } else { + this.logger.error(`Error: ${buildError.userFacingMessage}`); + } + return buildError; + } + + public getReactNativeProjectDirectory(baseDirectory = this.buildDirectory): string { + if (!this.job.platform) { + return path.join( + baseDirectory, + // NOTE: We may want to add projectRootDirectory to generic jobs in the future. + this.job.builderEnvironment.env.__EXPO_RELATIVE_BASE_DIRECTORY || '.' + ); + } + + return path.join(baseDirectory, this.job.projectRootDirectory ?? '.'); + } + + private setBuildPhase(buildPhase: BuildPhase, { doNotMarkStart = false } = {}): void { + if (this.buildPhase) { + if (this.buildPhase === buildPhase) { + return; + } else { + this.logger.info( + { marker: LogMarker.END_PHASE, result: BuildPhaseResult.UNKNOWN }, + `End phase: ${this.buildPhase}` + ); + this.logger = this.defaultLogger; + } + } + this.buildPhase = buildPhase; + this.logger = this.defaultLogger.child({ phase: buildPhase }); + if (!doNotMarkStart) { + this.logger.info({ marker: LogMarker.START_PHASE }, `Start phase: ${this.buildPhase}`); + } + } + + private async endCurrentBuildPhaseAsync({ + result, + doNotMarkEnd = false, + durationMs, + }: { + result: BuildPhaseResult; + doNotMarkEnd?: boolean; + durationMs: number; + }): Promise { + if (!this.buildPhase) { + return; + } + await this.collectAndUpdateEnvVariablesAsync(); + + this.reportBuildPhaseStats?.({ buildPhase: this.buildPhase, result, durationMs }); + + if (!doNotMarkEnd) { + this.logger.info( + { marker: LogMarker.END_PHASE, result, durationMs }, + `End phase: ${this.buildPhase}` + ); + } + this.logger = this.defaultLogger; + this.buildPhase = undefined; + this.buildPhaseSkipped = false; + this.buildPhaseHasWarnings = false; + } + + private async collectAndUpdateEnvVariablesAsync(): Promise { + const filenames = await fs.readdir(this.buildEnvsDirectory); + + const entries = await Promise.all( + filenames.map(async (basename) => { + const rawContents = await fs.readFile( + path.join(this.buildEnvsDirectory, basename), + 'utf-8' + ); + return [basename, rawContents]; + }) + ); + await Promise.all( + filenames.map(async (basename) => { + await fs.remove(path.join(this.buildEnvsDirectory, basename)); + }) + ); + this._env = { + ...this._env, + ...Object.fromEntries(entries), + }; + } + + private getEnvironmentSecrets(job: TJob): Record { + if (!job?.secrets?.environmentSecrets) { + return {}; + } + + const environmentSecretsDirectory = path.join(this.workingdir, 'eas-environment-secrets'); + + const environmentSecrets: Record = {}; + for (const { name, type, value } of job.secrets.environmentSecrets) { + if (type === EnvironmentSecretType.STRING) { + environmentSecrets[name] = value; + } else { + environmentSecrets[name] = createTemporaryEnvironmentSecretFile({ + secretsDir: environmentSecretsDirectory, + name, + contents_base64: value, + }); + } + } + return environmentSecrets; + } +} diff --git a/packages/build-tools/src/customBuildContext.ts b/packages/build-tools/src/customBuildContext.ts new file mode 100644 index 0000000000..16ba236ad0 --- /dev/null +++ b/packages/build-tools/src/customBuildContext.ts @@ -0,0 +1,137 @@ +import assert from 'assert'; +import path from 'path'; + +import { + BuildJob, + BuildPhase, + BuildTrigger, + Env, + Job, + Metadata, + Platform, + StaticJobInterpolationContext, +} from '@expo/eas-build-job'; +import { bunyan } from '@expo/logger'; +import { ExternalBuildContextProvider, BuildRuntimePlatform } from '@expo/steps'; + +import { ArtifactToUpload, BuildContext } from './context'; + +const platformToBuildRuntimePlatform: Record = { + [Platform.ANDROID]: BuildRuntimePlatform.LINUX, + [Platform.IOS]: BuildRuntimePlatform.DARWIN, +}; + +export interface BuilderRuntimeApi { + uploadArtifact: (spec: { artifact: ArtifactToUpload; logger: bunyan }) => Promise<{ + artifactId: string | null; + }>; +} + +export class CustomBuildContext implements ExternalBuildContextProvider { + /* + * Directory that contains project sources before eas/checkout. + */ + public readonly projectSourceDirectory: string; + + /* + * Directory where build is executed. eas/checkout will copy sources here. + */ + public readonly projectTargetDirectory: string; + + /* + * Directory where all build steps will be executed unless configured otherwise. + */ + public readonly defaultWorkingDirectory: string; + + /* + * Directory where build logs will be stored unless configure otherwise. + */ + public readonly buildLogsDirectory: string; + + public readonly logger: bunyan; + public readonly runtimeApi: BuilderRuntimeApi; + public job: TJob; + public metadata?: Metadata; + + private _env: Env; + + constructor(buildCtx: BuildContext) { + this._env = buildCtx.env; + this.job = buildCtx.job; + this.metadata = buildCtx.metadata; + + this.logger = buildCtx.logger.child({ phase: BuildPhase.CUSTOM }); + this.projectSourceDirectory = path.join(buildCtx.workingdir, 'temporary-custom-build'); + this.projectTargetDirectory = path.join(buildCtx.workingdir, 'build'); + this.defaultWorkingDirectory = buildCtx.getReactNativeProjectDirectory(); + this.buildLogsDirectory = path.join(buildCtx.workingdir, 'logs'); + this.runtimeApi = { + uploadArtifact: (...args) => buildCtx['uploadArtifact'](...args), + }; + } + + public hasBuildJob(): this is CustomBuildContext { + return Boolean(this.job.platform); + } + + public get runtimePlatform(): BuildRuntimePlatform { + // Generic jobs are not per-platform. + if (!this.job.platform) { + assert( + process.platform === 'linux' || process.platform === 'darwin', + `Invalid platform, expected linux or darwin, got: ${process.platform}` + ); + return { + linux: BuildRuntimePlatform.LINUX, + darwin: BuildRuntimePlatform.DARWIN, + }[process.platform]; + } + + return platformToBuildRuntimePlatform[this.job.platform]; + } + + public get env(): Env { + return this._env; + } + + // We omit steps, because CustomBuildContext does not have steps. + public staticContext(): Omit { + return { + ...this.job.workflowInterpolationContext, + expoApiServerURL: this.env.__API_SERVER_URL, + job: this.job, + metadata: this.metadata ?? null, + }; + } + + public updateEnv(env: Env): void { + this._env = env; + } + + public updateJobInformation(job: TJob, metadata: Metadata): void { + if (this.job.triggeredBy !== BuildTrigger.GIT_BASED_INTEGRATION) { + throw new Error( + 'Updating job information is only allowed when build was triggered by a git-based integration.' + ); + } + this.job = { + ...this.job, + ...job, + workflowInterpolationContext: + job.workflowInterpolationContext ?? this.job.workflowInterpolationContext, + triggeredBy: this.job.triggeredBy, + secrets: { + ...this.job.secrets, + ...job.secrets, + robotAccessToken: job.secrets?.robotAccessToken ?? this.job.secrets?.robotAccessToken, + environmentSecrets: [ + // Latter secrets override former ones. + ...(this.job.secrets?.environmentSecrets ?? []), + ...(job.secrets?.environmentSecrets ?? []), + ], + }, + ...(this.job.platform ? { expoBuildUrl: this.job.expoBuildUrl } : null), + }; + this.metadata = metadata; + } +} diff --git a/packages/build-tools/src/gcs/LoggerStream.ts b/packages/build-tools/src/gcs/LoggerStream.ts new file mode 100644 index 0000000000..e487bee4c8 --- /dev/null +++ b/packages/build-tools/src/gcs/LoggerStream.ts @@ -0,0 +1,238 @@ +import { randomUUID } from 'crypto'; +import os from 'os'; +import path from 'path'; +import zlib from 'zlib'; +import { Readable, Writable, pipeline } from 'stream'; +import assert from 'assert'; +import { promisify } from 'util'; + +import { bunyan } from '@expo/logger'; +import fs from 'fs-extra'; + +import GCS from './client'; + +type PromiseResolveFn = (value?: void | PromiseLike | undefined) => void; + +const pipe = promisify(pipeline); + +class GCSLoggerStream extends Writable { + public writable = true; + private readonly logger: bunyan; + private readonly uploadMethod?: GCSLoggerStream.UploadMethod; + private readonly options: GCSLoggerStream.Options; + private readonly temporaryLogsPath: string; + private readonly temporaryCompressedLogsPath: string; + private readonly compress: string | null; + private fileHandle?: number; + private uploadingPromise?: Promise; + private hasChangesToUpload = false; + private flushInterval?: NodeJS.Timeout; + private buffer: string[] = []; + private writePromise?: Promise; + private cleanUpCalled: boolean = false; + + constructor({ logger, uploadMethod, options }: GCSLoggerStream.Config) { + super(); + this.logger = logger; + this.uploadMethod = uploadMethod; + this.options = options; + this.compress = options?.compress ?? this.findNormalizedHeader('contentencoding'); + this.temporaryLogsPath = path.join(os.tmpdir(), `logs-${randomUUID()}`); + this.temporaryCompressedLogsPath = `${this.temporaryLogsPath}.compressed`; + } + + private findNormalizedHeader(name: string): string | null { + if (!this.uploadMethod || 'client' in this.uploadMethod) { + return null; + } + + const normalizedName = name.toLowerCase().replace('-', ''); + const fields = this.uploadMethod.signedUrl.headers; + for (const key in fields) { + if (key.toLowerCase().replace('-', '') === normalizedName) { + return fields[key]; + } + } + + return null; + } + + public async init(): Promise { + this.fileHandle = await fs.open(this.temporaryLogsPath, 'w+', 0o660); + this.flushInterval = setInterval(() => this.flush(), this.options.uploadIntervalMs); + return (await this.flush(true)) as string; + } + + public async cleanUp(): Promise { + if (this.cleanUpCalled) { + this.logger.info('Cleanup already called'); + return; + } + this.cleanUpCalled = true; + if (!this.fileHandle) { + throw new Error('You have to init the stream first!'); + } + if (this.flushInterval) { + clearInterval(this.flushInterval); + this.flushInterval = undefined; + } + + await this.safeWriteToFile(true); + await fs.close(this.fileHandle); + this.fileHandle = undefined; + + await this.flush(true); + + await fs.remove(this.temporaryLogsPath); + await fs.remove(this.temporaryCompressedLogsPath); + this.logger.info('Cleaning up GCS log stream'); + } + + public write(rec: any): boolean { + if (!this.fileHandle) { + return true; + } + const logLine = `${JSON.stringify(rec)}\n`; + this.buffer.push(logLine); + void this.safeWriteToFile(); + return true; + } + + private async safeWriteToFile(force = false): Promise { + if (this.writePromise && force) { + await this.writePromise; + await this.safeWriteToFile(force); + return; + } + + if (!this.fileHandle || Boolean(this.writePromise) || this.buffer.length === 0) { + return; + } + + const buffer = this.buffer.slice(); + try { + this.writePromise = fs.write(this.fileHandle, buffer.join('')); + await this.writePromise; + this.buffer = this.buffer.slice(buffer.length); + this.hasChangesToUpload = true; + } catch (err) { + this.logger.error({ err, origin: 'gcs-logger' }, 'Failed to write logs to file'); + } finally { + this.writePromise = undefined; + } + } + + private async flush(force = false): Promise { + if (force || this.hasChangesToUpload) { + if (this.uploadingPromise) { + await this.uploadingPromise; + return await this.flush(force); + } + return await this.flushInternal(); + } + } + + private async flushInternal(): Promise { + await this.safeWriteToFile(); + + let resolveFn: PromiseResolveFn; + this.hasChangesToUpload = false; + this.uploadingPromise = new Promise((res) => { + resolveFn = res; + }); + return await this.upload() + .then((result) => { + return result; + }) + .catch((err) => { + this.logger.error({ err }, 'Failed to upload logs file to GCS'); + }) + .then((result) => { + this.uploadingPromise = undefined; + resolveFn(); + return result; + }); + } + + private async upload(): Promise { + if (!this.uploadMethod) { + return; + } + + const { size } = await fs.stat(this.temporaryLogsPath); + const srcGeneratorAsync = async (): Promise => { + return await this.createCompressedStream( + fs.createReadStream(this.temporaryLogsPath, { end: size }) + ); + }; + + if ('signedUrl' in this.uploadMethod) { + return await GCS.uploadWithSignedUrl({ + signedUrl: this.uploadMethod.signedUrl, + srcGeneratorAsync, + }); + } + + const { Location } = await this.uploadMethod.client.uploadFile({ + key: this.uploadMethod.key, + src: await srcGeneratorAsync(), + streamOptions: { + metadata: { + contentType: 'text/plain;charset=utf-8', + ...(this.compress !== null ? { contentEncoding: this.compress } : {}), + customTime: this.uploadMethod.customTime, + }, + }, + }); + return Location; + } + + private async createCompressedStream(src: Readable): Promise { + if (!this.compress) { + return src; + } + + const dst = fs.createWriteStream(this.temporaryCompressedLogsPath); + + const encoder = + this.compress === 'gzip' + ? zlib.createGzip() + : this.compress === 'br' + ? zlib.createBrotliCompress({ + params: { + [zlib.constants.BROTLI_PARAM_QUALITY]: 0, + }, + }) + : null; + + assert(encoder, `unknown encoder ${encoder}`); + await pipe(src, encoder, dst); + + const { size } = await fs.stat(this.temporaryCompressedLogsPath); + return fs.createReadStream(this.temporaryCompressedLogsPath, { end: size }); + } +} + +namespace GCSLoggerStream { + export enum CompressionMethod { + GZIP = 'gzip', + BR = 'br', + } + + export interface Options { + uploadIntervalMs: number; + compress?: CompressionMethod; + } + + export type UploadMethod = + | { client: GCS; key: string; customTime: Date | null } + | { signedUrl: GCS.SignedUrl }; + + export interface Config { + logger: bunyan; + uploadMethod?: UploadMethod; + options: Options; + } +} + +export default GCSLoggerStream; diff --git a/packages/build-tools/src/gcs/__unit__/cat.jpg b/packages/build-tools/src/gcs/__unit__/cat.jpg new file mode 100644 index 0000000000..5e521e4220 Binary files /dev/null and b/packages/build-tools/src/gcs/__unit__/cat.jpg differ diff --git a/packages/build-tools/src/gcs/__unit__/gcs.test.ts b/packages/build-tools/src/gcs/__unit__/gcs.test.ts new file mode 100644 index 0000000000..5ed997f77f --- /dev/null +++ b/packages/build-tools/src/gcs/__unit__/gcs.test.ts @@ -0,0 +1,384 @@ +import path from 'path'; +import { randomBytes } from 'crypto'; +import { Readable } from 'stream'; + +import fetch, { RequestInit, Response } from 'node-fetch'; +import fs from 'fs-extra'; + +import GCS from '../client'; + +jest.mock('node-fetch'); + +class ErrorWithCode extends Error { + protected readonly _code: string | undefined; + + constructor(message: string, code?: string) { + super(message); + if (code) { + this._code = code; + } + } + + public get code(): string | undefined { + return this._code; + } +} + +class DNSError extends ErrorWithCode { + protected readonly _code: 'ENOTFOUND' | 'EAI_AGAIN' = 'ENOTFOUND'; +} + +const TEST_BUCKET = 'turtle-v2-test'; + +let googleApplicationCredentials: string | undefined; +beforeAll(() => { + googleApplicationCredentials = process.env.GOOGLE_APPLICATION_CREDENTIALS; + process.env.GOOGLE_APPLICATION_CREDENTIALS = path.join(__dirname, 'mock-credentials.json'); +}); + +afterAll(() => { + process.env.GOOGLE_APPLICATION_CREDENTIALS = googleApplicationCredentials; +}); + +describe('GCS client', () => { + describe('uploadWithPresignedURL function', () => { + it('should throw an error if upload fails', async () => { + const fetchMock = jest.mocked(fetch); + const res = { + ok: false, + status: 500, + } as Response; + + const localImagePath = path.join(__dirname, 'cat.jpg'); + const key = 'cat.jpg'; + + const gcs = new GCS(TEST_BUCKET); + const signedUrl = await gcs.createSignedUploadUrl({ + key, + expirationTime: 30000, + contentType: 'image/jpeg', + }); + + fetchMock.mockImplementation(async () => res); + await expect( + GCS.uploadWithSignedUrl({ + signedUrl, + srcGeneratorAsync: async () => fs.createReadStream(localImagePath), + retryIntervalMs: 500, + }) + ).rejects.toThrow(); + }); + + it('should return stripped URL if successful', async () => { + const fetchMock = jest.mocked(fetch); + const res = { + ok: true, + status: 200, + } as Response; + + const localImagePath = path.join(__dirname, 'cat.jpg'); + const key = 'cat.jpg'; + + const gcs = new GCS(TEST_BUCKET); + const signedUrl = await gcs.createSignedUploadUrl({ + key, + expirationTime: 30000, + contentType: 'image/jpeg', + }); + + fetchMock.mockImplementation(async () => res); + const result = await GCS.uploadWithSignedUrl({ + signedUrl, + srcGeneratorAsync: async () => fs.createReadStream(localImagePath), + retryIntervalMs: 500, + }); + expect(result).toEqual('https://storage.googleapis.com/turtle-v2-test/cat.jpg'); + }); + + it('should retry upload on DNS error up to 2 times', async () => { + const fetchMock = jest.mocked(fetch); + const res = { + ok: true, + status: 200, + } as Response; + + const localImagePath = path.join(__dirname, 'cat.jpg'); + const key = 'cat.jpg'; + + const gcs = new GCS(TEST_BUCKET); + const signedUrl = await gcs.createSignedUploadUrl({ + key, + expirationTime: 30000, + contentType: 'image/jpeg', + }); + + fetchMock + .mockImplementationOnce(async () => { + throw new DNSError('failed once'); + }) + .mockImplementationOnce(async () => { + throw new DNSError('failed twice', 'EAI_AGAIN'); + }) + .mockImplementation(async () => res); + const result = await GCS.uploadWithSignedUrl({ + signedUrl, + srcGeneratorAsync: async () => fs.createReadStream(localImagePath), + retryIntervalMs: 500, + }); + expect(result).toEqual('https://storage.googleapis.com/turtle-v2-test/cat.jpg'); + expect(fetchMock).toHaveBeenCalledTimes(3); + }); + + it('should retry upload on retriable status codes error up to 2 times', async () => { + const fetchMock = jest.mocked(fetch); + const res = { + ok: true, + status: 200, + } as Response; + + const localImagePath = path.join(__dirname, 'cat.jpg'); + const key = 'cat.jpg'; + + const gcs = new GCS(TEST_BUCKET); + const signedUrl = await gcs.createSignedUploadUrl({ + key, + expirationTime: 30000, + contentType: 'image/jpeg', + }); + + fetchMock + .mockImplementationOnce(async () => { + return { + ok: false, + status: 503, + } as Response; + }) + .mockImplementationOnce(async () => { + return { + ok: false, + status: 408, + } as Response; + }) + .mockImplementation(async () => res); + const result = await GCS.uploadWithSignedUrl({ + signedUrl, + srcGeneratorAsync: async () => fs.createReadStream(localImagePath), + retryIntervalMs: 500, + }); + expect(result).toEqual('https://storage.googleapis.com/turtle-v2-test/cat.jpg'); + expect(fetchMock).toHaveBeenCalledTimes(3); + }); + + it('should retry upload on retriable status codes and DNS errors error up to 2 times', async () => { + const fetchMock = jest.mocked(fetch); + const res = { + ok: true, + status: 200, + } as Response; + + const localImagePath = path.join(__dirname, 'cat.jpg'); + const key = 'cat.jpg'; + + const gcs = new GCS(TEST_BUCKET); + const signedUrl = await gcs.createSignedUploadUrl({ + key, + expirationTime: 30000, + contentType: 'image/jpeg', + }); + + fetchMock + .mockImplementationOnce(async () => { + return { + ok: false, + status: 503, + } as Response; + }) + .mockImplementationOnce(async () => { + throw new DNSError('failed once'); + }) + .mockImplementation(async () => res); + const result = await GCS.uploadWithSignedUrl({ + signedUrl, + srcGeneratorAsync: async () => fs.createReadStream(localImagePath), + retryIntervalMs: 500, + }); + expect(result).toEqual('https://storage.googleapis.com/turtle-v2-test/cat.jpg'); + expect(fetchMock).toHaveBeenCalledTimes(3); + }); + + it('should not retry upload on DNS error more than 2 times', async () => { + const fetchMock = jest.mocked(fetch); + const res = { + ok: true, + status: 200, + } as Response; + + const localImagePath = path.join(__dirname, 'cat.jpg'); + const key = 'cat.jpg'; + + const gcs = new GCS(TEST_BUCKET); + const signedUrl = await gcs.createSignedUploadUrl({ + key, + expirationTime: 30000, + contentType: 'image/jpeg', + }); + + const lastDNSError = new DNSError('failed thrice'); + fetchMock + .mockImplementationOnce(async () => { + throw new DNSError('failed once'); + }) + .mockImplementationOnce(async () => { + throw new DNSError('failed twice', 'EAI_AGAIN'); + }) + .mockImplementationOnce(async () => { + throw lastDNSError; + }) + .mockImplementation(async () => res); + await expect( + GCS.uploadWithSignedUrl({ + signedUrl, + srcGeneratorAsync: async () => fs.createReadStream(localImagePath), + retryIntervalMs: 500, + }) + ).rejects.toThrow(lastDNSError); + expect(fetchMock).toHaveBeenCalledTimes(3); + }); + + it('should not retry upload on retriable status code more than 2 times', async () => { + const fetchMock = jest.mocked(fetch); + const res = { + ok: true, + status: 200, + } as Response; + + const localImagePath = path.join(__dirname, 'cat.jpg'); + const key = 'cat.jpg'; + + const gcs = new GCS(TEST_BUCKET); + const signedUrl = await gcs.createSignedUploadUrl({ + key, + expirationTime: 30000, + contentType: 'image/jpeg', + }); + + fetchMock + .mockImplementationOnce(async () => { + return { + ok: false, + status: 503, + } as Response; + }) + .mockImplementationOnce(async () => { + return { + ok: false, + status: 408, + } as Response; + }) + .mockImplementationOnce(async () => { + return { + ok: false, + status: 504, + } as Response; + }) + .mockImplementation(async () => res); + await expect( + GCS.uploadWithSignedUrl({ + signedUrl, + srcGeneratorAsync: async () => fs.createReadStream(localImagePath), + retryIntervalMs: 500, + }) + ).rejects.toThrow(); + expect(fetchMock).toHaveBeenCalledTimes(3); + }); + + it('should not retry upload on other error codes', async () => { + const fetchMock = jest.mocked(fetch); + const res = { + ok: true, + status: 200, + } as Response; + + const localImagePath = path.join(__dirname, 'cat.jpg'); + const key = 'cat.jpg'; + + const gcs = new GCS(TEST_BUCKET); + const signedUrl = await gcs.createSignedUploadUrl({ + key, + expirationTime: 30000, + contentType: 'image/jpeg', + }); + + const nonDNSError = new ErrorWithCode('failed once', 'A_DIFFERENT_CODE'); + fetchMock + .mockImplementationOnce(async () => { + throw nonDNSError; + }) + .mockImplementation(async () => res); + await expect( + GCS.uploadWithSignedUrl({ + signedUrl, + srcGeneratorAsync: async () => fs.createReadStream(localImagePath), + retryIntervalMs: 500, + }) + ).rejects.toThrow(nonDNSError); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it('retries upload with full stream', async () => { + const fetchMock = jest.mocked(fetch); + const receivedBodies: Buffer[] = []; + const recordBody = async (request: RequestInit | undefined): Promise => { + let body = Buffer.from([]); + for await (const chunk of request!.body as Readable) { + body = Buffer.concat([body, chunk]); + } + receivedBodies.push(body); + }; + fetchMock + .mockImplementationOnce(async (_path, request) => { + await recordBody(request); + return { + ok: false, + status: 503, + } as Response; + }) + .mockImplementationOnce(async (_path, request) => { + await recordBody(request); + throw new DNSError('failed once'); + }) + .mockImplementation(async (_path, request) => { + await recordBody(request); + return { + ok: true, + status: 201, + } as Response; + }); + + const gcs = new GCS(TEST_BUCKET); + const signedUrl = await gcs.createSignedUploadUrl({ + key: 'text.txt', + expirationTime: 30000, + contentType: 'text/plain', + }); + + const bufferToUpload = randomBytes(16); + + const result = await GCS.uploadWithSignedUrl({ + signedUrl, + srcGeneratorAsync: async () => Readable.from(bufferToUpload), + retryIntervalMs: 500, + }); + expect(result).toEqual('https://storage.googleapis.com/turtle-v2-test/text.txt'); + expect(fetchMock).toHaveBeenCalledTimes(3); + expect(receivedBodies.length).toBe(3); + for (const body of receivedBodies) { + // Here we're testing that each body received in every retry is the same. + // If we passed the same body instance to each of the retries + // each retry would consume a chunk of the same stream, + // causing the uploaded file to be corrupted (missing first bytes). + expect(body).toStrictEqual(bufferToUpload); + } + }); + }); +}); diff --git a/packages/build-tools/src/gcs/__unit__/test-credentials.json b/packages/build-tools/src/gcs/__unit__/test-credentials.json new file mode 100644 index 0000000000..36c95e87d9 --- /dev/null +++ b/packages/build-tools/src/gcs/__unit__/test-credentials.json @@ -0,0 +1,13 @@ +{ + "type": "service_account", + "project_id": "mock-project", + "private_key_id": "mock-private-key-id", + "private_key": "-----BEGIN RSA PRIVATE KEY-----\nMIICXQIBAAKBgQCMk5Qbd5E8RQeP+2vObJVJVnXWXfW/OStxkAYe7r+KVXzHyLYU\nD6Ejt94OiC3X2+tqrMIZgNAUR3VF5tSiP57Il0iWHSspmNIHz7cW6/FHiQsvN2oJ\n8Fzz03vK/FfaU50QWO+BwRnixJ0xG1kSX3MxNyRZ5omGJgcwx0l6Jt878QIDAQAB\nAoGAcGZbqO6ceNU068H6/A1D/GSeSa3NHX2np+ChlEAPdJtP7yojs7yfekC801+9\nT/gurpe9hsjBF0XflemwIJ6/5KucWHrcp2cCkdBHtkK6ScHeIVl3DcOYYhxqJrAA\nYiyGNh1+pf3FMPFBcPJ04pQT4PqOif61/W3p8eJFUke5x7UCQQDoyJ427wrUCZb+\nj2GD5uVyxQa4OJxDbRFriK5LTdwLeFeOmQhoo6kaGywILn6UtlPTUJ38ROA4Jv58\nEJf/jssDAkEAmpi/JkWlOzSM9mnb9blt/fH/DRKu9z0JPvl47V5YAzBhUtUMK15g\nSbEl+AJZyaklpJcgv6ShiGY0PLdoY7oQ+wJBAMF6VX4lKqPYIKcN9ygRlm3Q8ufV\nLZQhKBRvsyYl3Zmu+V8tNL78IEXxhaR7OHxUGtINNHKDsLUbO/NUO3GOdo8CQQCV\nV0sIIK0+JUSq4ZYvqKI9d6Fnso28noSpBfuwabvh0MGjb9Viq7eeWHeSPksYSMLp\nXWiwWMwGZJy/rnk0JVEzAkADiiARp70BZgRB8ogBx+DGTravfhPP64eFMHJnUgAE\nmDKlj3v0x3OZfdUz6Us5hfy3WqFyYbcCGLf7joVVZAEm\n-----END RSA PRIVATE KEY-----", + "client_email": "mock-client-email", + "client_id": "mock-client-id", + "auth_uri": "https://accounts.google.com/o/oauth2/auth", + "token_uri": "https://oauth2.googleapis.com/token", + "auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs", + "client_x509_cert_url": "https://www.googleapis.com/robot/v1/metadata/x509/mock-client-email", + "universe_domain": "googleapis.com" +} diff --git a/packages/build-tools/src/gcs/client.ts b/packages/build-tools/src/gcs/client.ts new file mode 100644 index 0000000000..a105250b8a --- /dev/null +++ b/packages/build-tools/src/gcs/client.ts @@ -0,0 +1,211 @@ +import { promisify } from 'node:util'; +import stream from 'node:stream'; +import { Readable } from 'stream'; +import { URL } from 'url'; + +import fs from 'fs-extra'; +import { CreateWriteStreamOptions, File, Storage } from '@google-cloud/storage'; +import fetch, { FetchError } from 'node-fetch'; + +import { RetryOptions, retryOnGCSUploadFailure } from './retry'; + +const pipeline = promisify(stream.pipeline); + +interface SignedUrlParams { + key: string; + expirationTime: number; + contentType?: string; + extensionHeaders?: { [key: string]: number | string | string[] }; +} + +interface UploadWithSignedUrlParams { + signedUrl: GCS.SignedUrl; + srcGeneratorAsync: () => Promise; + retryIntervalMs?: RetryOptions['retryIntervalMs']; + retries?: RetryOptions['retries']; +} + +class GCS { + private readonly client = new Storage(); + + constructor(private readonly bucket: string) { + this.bucket = bucket; + } + + public static async uploadWithSignedUrl({ + signedUrl, + srcGeneratorAsync, + retries = 2, + retryIntervalMs = 30_000, + }: UploadWithSignedUrlParams): Promise { + let resp; + try { + resp = await retryOnGCSUploadFailure( + async () => { + const src = await srcGeneratorAsync(); + return await fetch(signedUrl.url, { + method: 'PUT', + headers: signedUrl.headers, + body: src, + }); + }, + { + retries, + retryIntervalMs, + } + ); + } catch (err: any) { + if (err instanceof FetchError) { + throw new Error(`Failed to upload the file, reason: ${err.code}`); + } else { + throw err; + } + } + if (!resp.ok) { + let body: string | undefined; + try { + body = await resp.text(); + } catch {} + throw new Error( + `Failed to upload file: status: ${resp.status} status text: ${resp.statusText}, body: ${body}` + ); + } + const url = new URL(signedUrl.url); + return `${url.protocol}//${url.host}${url.pathname}`; // strip query string + } + + public formatHttpUrl(key: string): string { + return this.client.bucket(this.bucket).file(key).publicUrl(); + } + + public async uploadFile({ + key, + src, + streamOptions, + }: { + key: string; + src: Readable; + streamOptions?: CreateWriteStreamOptions; + }): Promise<{ Location: string }> { + const file = this.client.bucket(this.bucket).file(key); + await new Promise((res, rej) => { + src.pipe( + file + .createWriteStream(streamOptions) + .on('error', (err) => { + rej(err); + }) + .on('finish', () => { + res(); + }) + ); + }); + return { Location: file.publicUrl() }; + } + + public async deleteFile(key: string): Promise { + try { + await this.client.bucket(this.bucket).file(key).delete(); + } catch (err: any) { + if (err.response?.statusCode === 404) { + return; + } + throw err; + } + } + + public async createSignedUploadUrl({ + key, + expirationTime, + contentType, + extensionHeaders = {}, + }: SignedUrlParams): Promise { + const config = { + version: 'v4' as const, + action: 'write' as const, + expires: Date.now() + expirationTime, + contentType, + extensionHeaders, + }; + + const [url] = await this.client.bucket(this.bucket).file(key).getSignedUrl(config); + + return { + url, + headers: { + ...(contentType ? { 'content-type': contentType } : {}), + ...extensionHeaders, + }, + }; + } + + public async createSignedDownloadUrl({ + key, + expirationTime, + }: { + key: string; + expirationTime: number; + }): Promise { + const options = { + version: 'v4' as const, + action: 'read' as const, + expires: Date.now() + expirationTime, + }; + + const [url] = await this.client.bucket(this.bucket).file(key).getSignedUrl(options); + + return url; + } + + public async checkIfFileExists(key: string, fileHash?: string): Promise { + let metadata; + try { + [metadata] = await this.client.bucket(this.bucket).file(key).getMetadata(); + } catch (error: any) { + if (error.code === 404) { + return false; + } + throw error; + } + + return fileHash ? metadata.etag === fileHash : true; + } + + public async listDirectory(prefix: string): Promise { + const [files] = await this.client.bucket(this.bucket).getFiles({ prefix }); + return files.map((x) => this.formatHttpUrl(x.name)); + } + + public async moveFile(src: string, dest: string): Promise { + await this.client.bucket(this.bucket).file(src).move(dest); + } + + public async deleteFiles(keys: string[]): Promise { + await Promise.all(keys.map((key) => this.deleteFile(key))); + } + + public async downloadFile(key: string, destinationPath: string): Promise { + const stream = this.client.bucket(this.bucket).file(key).createReadStream(); + await pipeline(stream, fs.createWriteStream(destinationPath)); + } + + public getFile(key: string): File { + return this.client.bucket(this.bucket).file(key); + } +} + +namespace GCS { + export interface SignedUrl { + url: string; + headers: { [key: string]: string }; + } + + export interface Config { + accessKeyId: string; + secretAccessKey: string; + region: string; + bucket: string; + } +} + +export default GCS; diff --git a/packages/build-tools/src/gcs/retry.ts b/packages/build-tools/src/gcs/retry.ts new file mode 100644 index 0000000000..0bba545056 --- /dev/null +++ b/packages/build-tools/src/gcs/retry.ts @@ -0,0 +1,65 @@ +import { Response } from 'node-fetch'; + +export interface RetryOptions { + retries: number; + retryIntervalMs: number; + shouldRetryOnError: (error: any) => boolean; + shouldRetryOnResponse: (response: Response) => boolean; +} + +// based on https://github.com/googleapis/nodejs-storage/blob/8ab50804fc7bae3bbd159bbb4adf65c02215b11b/src/storage.ts#L284-L320 +export async function retryOnGCSUploadFailure( + fn: (attemptCount: number) => Promise, + { + retries, + retryIntervalMs, + }: { retries: RetryOptions['retries']; retryIntervalMs: RetryOptions['retryIntervalMs'] } +): Promise { + return await retry(fn, { + retries, + retryIntervalMs, + shouldRetryOnError: (e) => { + return ( + e.code === 'ENOTFOUND' || + e.code === 'EAI_AGAIN' || + e.code === 'ECONNRESET' || + e.code === 'ETIMEDOUT' || + e.code === 'EPIPE' + ); + }, + shouldRetryOnResponse: (resp) => { + return [408, 429, 500, 502, 503, 504].includes(resp.status); + }, + }); +} + +/** + * Wrapper used to execute an inner function and possibly retry it if it throws and error + * @param fn Function to be executed and retried in case of error + * @param retries How many times at most should the function be retried + * @param retryIntervalMs Time interval between the retries + * @param shouldRetryOnError Function that determines if the function should be retried based on the error thrown + * @param shouldRetryOnResponse Function that determines if the function should be retried based on the response + */ +async function retry( + fn: (attemptCount: number) => Promise, + { retries, retryIntervalMs, shouldRetryOnError, shouldRetryOnResponse }: RetryOptions +): Promise { + let attemptCount = -1; + for (;;) { + try { + attemptCount += 1; + const resp = await fn(attemptCount); + if (attemptCount < retries && shouldRetryOnResponse(resp)) { + await new Promise((res) => setTimeout(res, retryIntervalMs)); + } else { + return resp; + } + } catch (err: any) { + if (attemptCount === retries || !shouldRetryOnError(err)) { + throw err; + } + await new Promise((res) => setTimeout(res, retryIntervalMs)); + } + } +} diff --git a/packages/build-tools/src/generic.ts b/packages/build-tools/src/generic.ts new file mode 100644 index 0000000000..0e778d197f --- /dev/null +++ b/packages/build-tools/src/generic.ts @@ -0,0 +1,70 @@ +import fs from 'fs/promises'; + +import { BuildPhase, Generic } from '@expo/eas-build-job'; +import { BuildStepGlobalContext, BuildWorkflow, errors, StepsConfigParser } from '@expo/steps'; +import { Result, asyncResult } from '@expo/results'; + +import { BuildContext } from './context'; +import { prepareProjectSourcesAsync } from './common/projectSources'; +import { getEasFunctions } from './steps/easFunctions'; +import { CustomBuildContext } from './customBuildContext'; +import { getEasFunctionGroups } from './steps/easFunctionGroups'; +import { uploadJobOutputsToWwwAsync } from './utils/outputs'; +import { retryAsync } from './utils/retry'; + +export async function runGenericJobAsync( + ctx: BuildContext, + { expoApiV2BaseUrl }: { expoApiV2BaseUrl: string } +): Promise<{ runResult: Result; buildWorkflow: BuildWorkflow }> { + const customBuildCtx = new CustomBuildContext(ctx); + + await ctx.runBuildPhase(BuildPhase.PREPARE_PROJECT, async () => { + await retryAsync( + async () => { + await fs.rm(customBuildCtx.projectSourceDirectory, { recursive: true, force: true }); + await fs.mkdir(customBuildCtx.projectSourceDirectory, { recursive: true }); + + await prepareProjectSourcesAsync(ctx, customBuildCtx.projectSourceDirectory); + }, + { + retryOptions: { + retries: 3, + retryIntervalMs: 1_000, + }, + } + ); + }); + + const globalContext = new BuildStepGlobalContext(customBuildCtx, false); + + const parser = new StepsConfigParser(globalContext, { + externalFunctions: getEasFunctions(customBuildCtx), + externalFunctionGroups: getEasFunctionGroups(customBuildCtx), + steps: ctx.job.steps, + }); + + const workflow = await ctx.runBuildPhase(BuildPhase.PARSE_CUSTOM_WORKFLOW_CONFIG, async () => { + try { + return await parser.parseAsync(); + } catch (parseError: any) { + ctx.logger.error('Failed to parse the job definition file.'); + if (parseError instanceof errors.BuildWorkflowError) { + for (const err of parseError.errors) { + ctx.logger.error({ err }); + } + } + throw parseError; + } + }); + + const runResult = await asyncResult(workflow.executeAsync()); + + await ctx.runBuildPhase(BuildPhase.COMPLETE_JOB, async () => { + await uploadJobOutputsToWwwAsync(globalContext, { + logger: ctx.logger, + expoApiV2BaseUrl, + }); + }); + + return { runResult, buildWorkflow: workflow }; +} diff --git a/packages/build-tools/src/index.ts b/packages/build-tools/src/index.ts new file mode 100644 index 0000000000..b5877ca5e7 --- /dev/null +++ b/packages/build-tools/src/index.ts @@ -0,0 +1,23 @@ +import * as Builders from './builders'; +import GCS from './gcs/client'; +import GCSLoggerStream from './gcs/LoggerStream'; + +export { Builders, GCS, GCSLoggerStream }; + +export { + ArtifactToUpload, + Artifacts, + BuildContext, + BuildContextOptions, + CacheManager, + LogBuffer, + SkipNativeBuildError, +} from './context'; + +export { PackageManager } from './utils/packageManager'; + +export { findAndUploadXcodeBuildLogsAsync } from './ios/xcodeBuildLogs'; + +export { Hook, runHookIfPresent } from './utils/hooks'; + +export * from './generic'; diff --git a/packages/build-tools/src/ios/__tests__/__snapshots__/configure.test.ts.snap b/packages/build-tools/src/ios/__tests__/__snapshots__/configure.test.ts.snap new file mode 100644 index 0000000000..83adc3c7d9 --- /dev/null +++ b/packages/build-tools/src/ios/__tests__/__snapshots__/configure.test.ts.snap @@ -0,0 +1,2566 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`configureXcodeProject configures credentials and versions for a simple project 1`] = ` +"// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 46; + objects = { + +/* Begin PBXBuildFile section */ + 13B07FBC1A68108700A75B9A /* AppDelegate.m in Sources */ = {isa = PBXBuildFile; fileRef = 13B07FB01A68108700A75B9A /* AppDelegate.m */; }; + 13B07FBF1A68108700A75B9A /* Images.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 13B07FB51A68108700A75B9A /* Images.xcassets */; }; + 13B07FC11A68108700A75B9A /* main.m in Sources */ = {isa = PBXBuildFile; fileRef = 13B07FB71A68108700A75B9A /* main.m */; }; + 3E461D99554A48A4959DE609 /* SplashScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = AA286B85B6C04FC6940260E9 /* SplashScreen.storyboard */; }; + 96905EF65AED1B983A6B3ABC /* libPods-testapp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 58EEBF8E8E6FB1BC6CAF49B5 /* libPods-testapp.a */; }; + B18059E884C0ABDD17F3DC3D /* ExpoModulesProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = FAC715A2D49A985799AEE119 /* ExpoModulesProvider.swift */; }; + BB2F792D24A3F905000567C9 /* Expo.plist in Resources */ = {isa = PBXBuildFile; fileRef = BB2F792C24A3F905000567C9 /* Expo.plist */; }; + E112987CA504453081AC7CD8 /* noop-file.swift in Sources */ = {isa = PBXBuildFile; fileRef = C911AA20BDD841449D1CE041 /* noop-file.swift */; }; +/* End PBXBuildFile section */ + +/* Begin PBXFileReference section */ + 008F07F21AC5B25A0029DE68 /* main.jsbundle */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text; path = main.jsbundle; sourceTree = ""; }; + 13B07F961A680F5B00A75B9A /* testapp.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = testapp.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 13B07FAF1A68108700A75B9A /* AppDelegate.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; name = AppDelegate.h; path = testapp/AppDelegate.h; sourceTree = ""; }; + 13B07FB01A68108700A75B9A /* AppDelegate.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; name = AppDelegate.m; path = testapp/AppDelegate.m; sourceTree = ""; }; + 13B07FB51A68108700A75B9A /* Images.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; name = Images.xcassets; path = testapp/Images.xcassets; sourceTree = ""; }; + 13B07FB61A68108700A75B9A /* Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = Info.plist; path = testapp/Info.plist; sourceTree = ""; }; + 13B07FB71A68108700A75B9A /* main.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; name = main.m; path = testapp/main.m; sourceTree = ""; }; + 58EEBF8E8E6FB1BC6CAF49B5 /* libPods-testapp.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = "libPods-testapp.a"; sourceTree = BUILT_PRODUCTS_DIR; }; + 6C2E3173556A471DD304B334 /* Pods-testapp.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-testapp.debug.xcconfig"; path = "Target Support Files/Pods-testapp/Pods-testapp.debug.xcconfig"; sourceTree = ""; }; + 7A4D352CD337FB3A3BF06240 /* Pods-testapp.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-testapp.release.xcconfig"; path = "Target Support Files/Pods-testapp/Pods-testapp.release.xcconfig"; sourceTree = ""; }; + AA286B85B6C04FC6940260E9 /* SplashScreen.storyboard */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.storyboard; name = SplashScreen.storyboard; path = testapp/SplashScreen.storyboard; sourceTree = ""; }; + BB2F792C24A3F905000567C9 /* Expo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = Expo.plist; sourceTree = ""; }; + ED297162215061F000B7C4FE /* JavaScriptCore.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = JavaScriptCore.framework; path = System/Library/Frameworks/JavaScriptCore.framework; sourceTree = SDKROOT; }; + FAC715A2D49A985799AEE119 /* ExpoModulesProvider.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = ExpoModulesProvider.swift; path = "Pods/Target Support Files/Pods-testapp/ExpoModulesProvider.swift"; sourceTree = ""; }; + C911AA20BDD841449D1CE041 /* noop-file.swift */ = {isa = PBXFileReference; name = "noop-file.swift"; path = "testapp/noop-file.swift"; sourceTree = ""; fileEncoding = 4; lastKnownFileType = sourcecode.swift; explicitFileType = undefined; includeInIndex = 0; }; + 0962172184234A4793928022 /* testapp-Bridging-Header.h */ = {isa = PBXFileReference; name = "testapp-Bridging-Header.h"; path = "testapp/testapp-Bridging-Header.h"; sourceTree = ""; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; explicitFileType = undefined; includeInIndex = 0; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + 13B07F8C1A680F5B00A75B9A /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 96905EF65AED1B983A6B3ABC /* libPods-testapp.a in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 13B07FAE1A68108700A75B9A /* testapp */ = { + isa = PBXGroup; + children = ( + BB2F792B24A3F905000567C9 /* Supporting */, + 008F07F21AC5B25A0029DE68 /* main.jsbundle */, + 13B07FAF1A68108700A75B9A /* AppDelegate.h */, + 13B07FB01A68108700A75B9A /* AppDelegate.m */, + 13B07FB51A68108700A75B9A /* Images.xcassets */, + 13B07FB61A68108700A75B9A /* Info.plist */, + 13B07FB71A68108700A75B9A /* main.m */, + AA286B85B6C04FC6940260E9 /* SplashScreen.storyboard */, + C911AA20BDD841449D1CE041 /* noop-file.swift */, + 0962172184234A4793928022 /* testapp-Bridging-Header.h */, + ); + name = testapp; + sourceTree = ""; + }; + 2D16E6871FA4F8E400B85C8A /* Frameworks */ = { + isa = PBXGroup; + children = ( + ED297162215061F000B7C4FE /* JavaScriptCore.framework */, + 58EEBF8E8E6FB1BC6CAF49B5 /* libPods-testapp.a */, + ); + name = Frameworks; + sourceTree = ""; + }; + 832341AE1AAA6A7D00B99B32 /* Libraries */ = { + isa = PBXGroup; + children = ( + ); + name = Libraries; + sourceTree = ""; + }; + 83CBB9F61A601CBA00E9B192 = { + isa = PBXGroup; + children = ( + 13B07FAE1A68108700A75B9A /* testapp */, + 832341AE1AAA6A7D00B99B32 /* Libraries */, + 83CBBA001A601CBA00E9B192 /* Products */, + 2D16E6871FA4F8E400B85C8A /* Frameworks */, + D65327D7A22EEC0BE12398D9 /* Pods */, + D7E4C46ADA2E9064B798F356 /* ExpoModulesProviders */, + ); + indentWidth = 2; + sourceTree = ""; + tabWidth = 2; + usesTabs = 0; + }; + 83CBBA001A601CBA00E9B192 /* Products */ = { + isa = PBXGroup; + children = ( + 13B07F961A680F5B00A75B9A /* testapp.app */, + ); + name = Products; + sourceTree = ""; + }; + 92DBD88DE9BF7D494EA9DA96 /* testapp */ = { + isa = PBXGroup; + children = ( + FAC715A2D49A985799AEE119 /* ExpoModulesProvider.swift */, + ); + name = testapp; + sourceTree = ""; + }; + BB2F792B24A3F905000567C9 /* Supporting */ = { + isa = PBXGroup; + children = ( + BB2F792C24A3F905000567C9 /* Expo.plist */, + ); + name = Supporting; + path = testapp/Supporting; + sourceTree = ""; + }; + D65327D7A22EEC0BE12398D9 /* Pods */ = { + isa = PBXGroup; + children = ( + 6C2E3173556A471DD304B334 /* Pods-testapp.debug.xcconfig */, + 7A4D352CD337FB3A3BF06240 /* Pods-testapp.release.xcconfig */, + ); + path = Pods; + sourceTree = ""; + }; + D7E4C46ADA2E9064B798F356 /* ExpoModulesProviders */ = { + isa = PBXGroup; + children = ( + 92DBD88DE9BF7D494EA9DA96 /* testapp */, + ); + name = ExpoModulesProviders; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 13B07F861A680F5B00A75B9A /* testapp */ = { + isa = PBXNativeTarget; + buildConfigurationList = 13B07F931A680F5B00A75B9A /* Build configuration list for PBXNativeTarget "testapp" */; + buildPhases = ( + 08A4A3CD28434E44B6B9DE2E /* [CP] Check Pods Manifest.lock */, + FD10A7F022414F080027D42C /* Start Packager */, + 13B07F871A680F5B00A75B9A /* Sources */, + 13B07F8C1A680F5B00A75B9A /* Frameworks */, + 13B07F8E1A680F5B00A75B9A /* Resources */, + 00DD1BFF1BD5951E006B06BC /* Bundle React Native code and images */, + 800E24972A6A228C8D4807E9 /* [CP] Copy Pods Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = testapp; + productName = testapp; + productReference = 13B07F961A680F5B00A75B9A /* testapp.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 83CBB9F71A601CBA00E9B192 /* Project object */ = { + isa = PBXProject; + attributes = { + LastUpgradeCheck = 1130; + TargetAttributes = { + 13B07F861A680F5B00A75B9A = { + LastSwiftMigration = 1250; + DevelopmentTeam = "ABCDEFGH"; + ProvisioningStyle = Manual; + }; + }; + }; + buildConfigurationList = 83CBB9FA1A601CBA00E9B192 /* Build configuration list for PBXProject "testapp" */; + compatibilityVersion = "Xcode 3.2"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = 83CBB9F61A601CBA00E9B192; + productRefGroup = 83CBBA001A601CBA00E9B192 /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 13B07F861A680F5B00A75B9A /* testapp */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 13B07F8E1A680F5B00A75B9A /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + BB2F792D24A3F905000567C9 /* Expo.plist in Resources */, + 13B07FBF1A68108700A75B9A /* Images.xcassets in Resources */, + 3E461D99554A48A4959DE609 /* SplashScreen.storyboard in Resources */, + C9A08D1C39C94E5D824DFA3F /* testapp-Bridging-Header.h in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXShellScriptBuildPhase section */ + 00DD1BFF1BD5951E006B06BC /* Bundle React Native code and images */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + ); + name = "Bundle React Native code and images"; + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "export NODE_BINARY=node\\n\\n# The project root by default is one level up from the ios directory\\nexport PROJECT_ROOT=\\"$PROJECT_DIR\\"/..\\n\\n\`node --print \\"require('path').dirname(require.resolve('react-native/package.json')) + '/scripts/react-native-xcode.sh'\\"\`\\n"; + }; + 08A4A3CD28434E44B6B9DE2E /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "\${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "\${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-testapp-checkManifestLockResult.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "diff \\"\${PODS_PODFILE_DIR_PATH}/Podfile.lock\\" \\"\${PODS_ROOT}/Manifest.lock\\" > /dev/null\\nif [ $? != 0 ] ; then\\n # print error to STDERR\\n echo \\"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\\" >&2\\n exit 1\\nfi\\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\\necho \\"SUCCESS\\" > \\"\${SCRIPT_OUTPUT_FILE_0}\\"\\n"; + showEnvVarsInLog = 0; + }; + 800E24972A6A228C8D4807E9 /* [CP] Copy Pods Resources */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + "\${PODS_ROOT}/Target Support Files/Pods-testapp/Pods-testapp-resources.sh", + "\${PODS_CONFIGURATION_BUILD_DIR}/EXConstants/EXConstants.bundle", + "\${PODS_CONFIGURATION_BUILD_DIR}/EXUpdates/EXUpdates.bundle", + "\${PODS_CONFIGURATION_BUILD_DIR}/React-Core/AccessibilityResources.bundle", + ); + name = "[CP] Copy Pods Resources"; + outputPaths = ( + "\${TARGET_BUILD_DIR}/\${UNLOCALIZED_RESOURCES_FOLDER_PATH}/EXConstants.bundle", + "\${TARGET_BUILD_DIR}/\${UNLOCALIZED_RESOURCES_FOLDER_PATH}/EXUpdates.bundle", + "\${TARGET_BUILD_DIR}/\${UNLOCALIZED_RESOURCES_FOLDER_PATH}/AccessibilityResources.bundle", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\\"\${PODS_ROOT}/Target Support Files/Pods-testapp/Pods-testapp-resources.sh\\"\\n"; + showEnvVarsInLog = 0; + }; + FD10A7F022414F080027D42C /* Start Packager */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + ); + name = "Start Packager"; + outputFileListPaths = ( + ); + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "export RCT_METRO_PORT=\\"\${RCT_METRO_PORT:=8081}\\"\\necho \\"export RCT_METRO_PORT=\${RCT_METRO_PORT}\\" > \`node --print \\"require('path').dirname(require.resolve('react-native/package.json')) + '/scripts/.packager.env'\\"\`\\nif [ -z \\"\${RCT_NO_LAUNCH_PACKAGER+xxx}\\" ] ; then\\n if nc -w 5 -z localhost \${RCT_METRO_PORT} ; then\\n if ! curl -s \\"http://localhost:\${RCT_METRO_PORT}/status\\" | grep -q \\"packager-status:running\\" ; then\\n echo \\"Port \${RCT_METRO_PORT} already in use, packager is either not running or not running correctly\\"\\n exit 2\\n fi\\n else\\n open \`node --print \\"require('path').dirname(require.resolve('react-native/package.json')) + '/scripts/launchPackager.command'\\"\` || echo \\"Can't start packager automatically\\"\\n fi\\nfi\\n"; + showEnvVarsInLog = 0; + }; +/* End PBXShellScriptBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 13B07F871A680F5B00A75B9A /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 13B07FBC1A68108700A75B9A /* AppDelegate.m in Sources */, + 13B07FC11A68108700A75B9A /* main.m in Sources */, + B18059E884C0ABDD17F3DC3D /* ExpoModulesProvider.swift in Sources */, + E112987CA504453081AC7CD8 /* noop-file.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin XCBuildConfiguration section */ + 13B07F941A680F5B00A75B9A /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 6C2E3173556A471DD304B334 /* Pods-testapp.debug.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CURRENT_PROJECT_VERSION = 1; + ENABLE_BITCODE = NO; + GCC_PREPROCESSOR_DEFINITIONS = ( + "$(inherited)", + "FB_SONARKIT_ENABLED=1", + ); + INFOPLIST_FILE = testapp/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 12.0; + LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; + OTHER_LDFLAGS = ( + "$(inherited)", + "-ObjC", + "-lc++", + ); + PRODUCT_BUNDLE_IDENTIFIER = "org.reactjs.native.example.testapp.turtlev2"; + PRODUCT_NAME = turtlev2; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + TARGETED_DEVICE_FAMILY = "1,2"; + SWIFT_OBJC_BRIDGING_HEADER = testapp/testapp-Bridging-Header.h; + CODE_SIGN_ENTITLEMENTS = testapp/turtlev2.entitlements; + }; + name = Debug; + }; + 13B07F951A680F5B00A75B9A /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7A4D352CD337FB3A3BF06240 /* Pods-testapp.release.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CURRENT_PROJECT_VERSION = 1; + INFOPLIST_FILE = testapp/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 12.0; + LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; + OTHER_LDFLAGS = ( + "$(inherited)", + "-ObjC", + "-lc++", + ); + PRODUCT_BUNDLE_IDENTIFIER = "org.reactjs.native.example.testapp.turtlev2"; + PRODUCT_NAME = turtlev2; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + TARGETED_DEVICE_FAMILY = "1,2"; + SWIFT_OBJC_BRIDGING_HEADER = testapp/testapp-Bridging-Header.h; + CODE_SIGN_ENTITLEMENTS = testapp/turtlev2.entitlements; + PROVISIONING_PROFILE_SPECIFIER = "profile name"; + DEVELOPMENT_TEAM = "ABCDEFGH"; + CODE_SIGN_IDENTITY = "iPhone Distribution"; + CODE_SIGN_STYLE = Manual; + }; + name = Release; + }; + 83CBBA201A601CBA00E9B192 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_SYMBOLS_PRIVATE_EXTERN = NO; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 12.0; + LD_RUNPATH_SEARCH_PATHS = "/usr/lib/swift $(inherited)"; + LIBRARY_SEARCH_PATHS = "\\"$(inherited)\\""; + MTL_ENABLE_DEBUG_INFO = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = iphoneos; + CODE_SIGN_ENTITLEMENTS = testapp/turtlev2.entitlements; + }; + name = Debug; + }; + 83CBBA211A601CBA00E9B192 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = YES; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 12.0; + LD_RUNPATH_SEARCH_PATHS = "/usr/lib/swift $(inherited)"; + LIBRARY_SEARCH_PATHS = "\\"$(inherited)\\""; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = iphoneos; + VALIDATE_PRODUCT = YES; + CODE_SIGN_ENTITLEMENTS = testapp/turtlev2.entitlements; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 13B07F931A680F5B00A75B9A /* Build configuration list for PBXNativeTarget "testapp" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 13B07F941A680F5B00A75B9A /* Debug */, + 13B07F951A680F5B00A75B9A /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 83CBB9FA1A601CBA00E9B192 /* Build configuration list for PBXProject "testapp" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 83CBBA201A601CBA00E9B192 /* Debug */, + 83CBBA211A601CBA00E9B192 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + }; + rootObject = 83CBB9F71A601CBA00E9B192 /* Project object */; +} +" +`; + +exports[`configureXcodeProject configures credentials and versions for a simple project: Info.plist application target 1`] = ` +" + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + testapp + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + $(PRODUCT_BUNDLE_PACKAGE_TYPE) + CFBundleShortVersionString + 1.2.3 + CFBundleSignature + ???? + CFBundleURLTypes + + + CFBundleURLSchemes + + org.reactjs.native.example.testapp.turtlev2 + + + + CFBundleVersion + 1.2.4 + LSRequiresIPhoneOS + + NSAppTransportSecurity + + NSAllowsArbitraryLoads + + NSExceptionDomains + + localhost + + NSExceptionAllowsInsecureHTTPLoads + + + + + UILaunchStoryboardName + SplashScreen + UIRequiredDeviceCapabilities + + armv7 + + UIRequiresFullScreen + + UIStatusBarStyle + UIStatusBarStyleDefault + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + + UISupportedInterfaceOrientations~ipad + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UIUserInterfaceStyle + Light + UIViewControllerBasedStatusBarAppearance + + +" +`; + +exports[`configureXcodeProject configures credentials and versions for multi target project 1`] = ` +"// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 46; + objects = { + +/* Begin PBXBuildFile section */ + 13B07FBC1A68108700A75B9A /* AppDelegate.m in Sources */ = {isa = PBXBuildFile; fileRef = 13B07FB01A68108700A75B9A /* AppDelegate.m */; }; + 13B07FBD1A68108700A75B9A /* LaunchScreen.xib in Resources */ = {isa = PBXBuildFile; fileRef = 13B07FB11A68108700A75B9A /* LaunchScreen.xib */; }; + 13B07FBF1A68108700A75B9A /* Images.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 13B07FB51A68108700A75B9A /* Images.xcassets */; }; + 13B07FC11A68108700A75B9A /* main.m in Sources */ = {isa = PBXBuildFile; fileRef = 13B07FB71A68108700A75B9A /* main.m */; }; + 3E461D99554A48A4959DE609 /* SplashScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = AA286B85B6C04FC6940260E9 /* SplashScreen.storyboard */; }; + 6ADAD6E626493A420001F56E /* ShareViewController.m in Sources */ = {isa = PBXBuildFile; fileRef = 6ADAD6E526493A420001F56E /* ShareViewController.m */; }; + 6ADAD6E926493A420001F56E /* MainInterface.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 6ADAD6E726493A420001F56E /* MainInterface.storyboard */; }; + 6ADAD6ED26493A420001F56E /* shareextension.appex in Embed App Extensions */ = {isa = PBXBuildFile; fileRef = 6ADAD6E226493A420001F56E /* shareextension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; + 96905EF65AED1B983A6B3ABC /* libPods-multitarget.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 58EEBF8E8E6FB1BC6CAF49B5 /* libPods-multitarget.a */; }; + BB2F792D24A3F905000567C9 /* Expo.plist in Resources */ = {isa = PBXBuildFile; fileRef = BB2F792C24A3F905000567C9 /* Expo.plist */; }; +/* End PBXBuildFile section */ + +/* Begin PBXContainerItemProxy section */ + 6ADAD6EB26493A420001F56E /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 83CBB9F71A601CBA00E9B192 /* Project object */; + proxyType = 1; + remoteGlobalIDString = 6ADAD6E126493A420001F56E; + remoteInfo = shareextension; + }; +/* End PBXContainerItemProxy section */ + +/* Begin PBXCopyFilesBuildPhase section */ + 6ADAD6F126493A420001F56E /* Embed App Extensions */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 13; + files = ( + 6ADAD6ED26493A420001F56E /* shareextension.appex in Embed App Extensions */, + ); + name = "Embed App Extensions"; + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXCopyFilesBuildPhase section */ + +/* Begin PBXFileReference section */ + 008F07F21AC5B25A0029DE68 /* main.jsbundle */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text; path = main.jsbundle; sourceTree = ""; }; + 13B07F961A680F5B00A75B9A /* multitarget.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = multitarget.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 13B07FAF1A68108700A75B9A /* AppDelegate.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; name = AppDelegate.h; path = multitarget/AppDelegate.h; sourceTree = ""; }; + 13B07FB01A68108700A75B9A /* AppDelegate.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; name = AppDelegate.m; path = multitarget/AppDelegate.m; sourceTree = ""; }; + 13B07FB21A68108700A75B9A /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.xib; name = Base; path = Base.lproj/LaunchScreen.xib; sourceTree = ""; }; + 13B07FB51A68108700A75B9A /* Images.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; name = Images.xcassets; path = multitarget/Images.xcassets; sourceTree = ""; }; + 13B07FB61A68108700A75B9A /* Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = Info.plist; path = multitarget/Info.plist; sourceTree = ""; }; + 13B07FB71A68108700A75B9A /* main.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; name = main.m; path = multitarget/main.m; sourceTree = ""; }; + 58EEBF8E8E6FB1BC6CAF49B5 /* libPods-multitarget.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = "libPods-multitarget.a"; sourceTree = BUILT_PRODUCTS_DIR; }; + 6ADAD6E226493A420001F56E /* shareextension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = shareextension.appex; sourceTree = BUILT_PRODUCTS_DIR; }; + 6ADAD6E426493A420001F56E /* ShareViewController.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = ShareViewController.h; sourceTree = ""; }; + 6ADAD6E526493A420001F56E /* ShareViewController.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = ShareViewController.m; sourceTree = ""; }; + 6ADAD6E826493A420001F56E /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/MainInterface.storyboard; sourceTree = ""; }; + 6ADAD6EA26493A420001F56E /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + 6C2E3173556A471DD304B334 /* Pods-multitarget.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-multitarget.debug.xcconfig"; path = "Target Support Files/Pods-multitarget/Pods-multitarget.debug.xcconfig"; sourceTree = ""; }; + 7A4D352CD337FB3A3BF06240 /* Pods-multitarget.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-multitarget.release.xcconfig"; path = "Target Support Files/Pods-multitarget/Pods-multitarget.release.xcconfig"; sourceTree = ""; }; + AA286B85B6C04FC6940260E9 /* SplashScreen.storyboard */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.storyboard; name = SplashScreen.storyboard; path = multitarget/SplashScreen.storyboard; sourceTree = ""; }; + BB2F792C24A3F905000567C9 /* Expo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = Expo.plist; sourceTree = ""; }; + ED297162215061F000B7C4FE /* JavaScriptCore.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = JavaScriptCore.framework; path = System/Library/Frameworks/JavaScriptCore.framework; sourceTree = SDKROOT; }; + ED2971642150620600B7C4FE /* JavaScriptCore.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = JavaScriptCore.framework; path = Platforms/AppleTVOS.platform/Developer/SDKs/AppleTVOS12.0.sdk/System/Library/Frameworks/JavaScriptCore.framework; sourceTree = DEVELOPER_DIR; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + 13B07F8C1A680F5B00A75B9A /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 96905EF65AED1B983A6B3ABC /* libPods-multitarget.a in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 6ADAD6DF26493A420001F56E /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 13B07FAE1A68108700A75B9A /* multitarget */ = { + isa = PBXGroup; + children = ( + BB2F792B24A3F905000567C9 /* Supporting */, + 008F07F21AC5B25A0029DE68 /* main.jsbundle */, + 13B07FAF1A68108700A75B9A /* AppDelegate.h */, + 13B07FB01A68108700A75B9A /* AppDelegate.m */, + 13B07FB51A68108700A75B9A /* Images.xcassets */, + 13B07FB61A68108700A75B9A /* Info.plist */, + 13B07FB11A68108700A75B9A /* LaunchScreen.xib */, + 13B07FB71A68108700A75B9A /* main.m */, + AA286B85B6C04FC6940260E9 /* SplashScreen.storyboard */, + ); + name = multitarget; + sourceTree = ""; + }; + 2D16E6871FA4F8E400B85C8A /* Frameworks */ = { + isa = PBXGroup; + children = ( + ED297162215061F000B7C4FE /* JavaScriptCore.framework */, + ED2971642150620600B7C4FE /* JavaScriptCore.framework */, + 58EEBF8E8E6FB1BC6CAF49B5 /* libPods-multitarget.a */, + ); + name = Frameworks; + sourceTree = ""; + }; + 6ADAD6E326493A420001F56E /* shareextension */ = { + isa = PBXGroup; + children = ( + 6ADAD6E426493A420001F56E /* ShareViewController.h */, + 6ADAD6E526493A420001F56E /* ShareViewController.m */, + 6ADAD6E726493A420001F56E /* MainInterface.storyboard */, + 6ADAD6EA26493A420001F56E /* Info.plist */, + ); + path = shareextension; + sourceTree = ""; + }; + 832341AE1AAA6A7D00B99B32 /* Libraries */ = { + isa = PBXGroup; + children = ( + ); + name = Libraries; + sourceTree = ""; + }; + 83CBB9F61A601CBA00E9B192 = { + isa = PBXGroup; + children = ( + 13B07FAE1A68108700A75B9A /* multitarget */, + 832341AE1AAA6A7D00B99B32 /* Libraries */, + 6ADAD6E326493A420001F56E /* shareextension */, + 83CBBA001A601CBA00E9B192 /* Products */, + 2D16E6871FA4F8E400B85C8A /* Frameworks */, + D65327D7A22EEC0BE12398D9 /* Pods */, + ); + indentWidth = 2; + sourceTree = ""; + tabWidth = 2; + usesTabs = 0; + }; + 83CBBA001A601CBA00E9B192 /* Products */ = { + isa = PBXGroup; + children = ( + 13B07F961A680F5B00A75B9A /* multitarget.app */, + 6ADAD6E226493A420001F56E /* shareextension.appex */, + ); + name = Products; + sourceTree = ""; + }; + BB2F792B24A3F905000567C9 /* Supporting */ = { + isa = PBXGroup; + children = ( + BB2F792C24A3F905000567C9 /* Expo.plist */, + ); + name = Supporting; + path = multitarget/Supporting; + sourceTree = ""; + }; + D65327D7A22EEC0BE12398D9 /* Pods */ = { + isa = PBXGroup; + children = ( + 6C2E3173556A471DD304B334 /* Pods-multitarget.debug.xcconfig */, + 7A4D352CD337FB3A3BF06240 /* Pods-multitarget.release.xcconfig */, + ); + path = Pods; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 13B07F861A680F5B00A75B9A /* multitarget */ = { + isa = PBXNativeTarget; + buildConfigurationList = 13B07F931A680F5B00A75B9A /* Build configuration list for PBXNativeTarget "multitarget" */; + buildPhases = ( + 08A4A3CD28434E44B6B9DE2E /* [CP] Check Pods Manifest.lock */, + FD10A7F022414F080027D42C /* Start Packager */, + 13B07F871A680F5B00A75B9A /* Sources */, + 13B07F8C1A680F5B00A75B9A /* Frameworks */, + 13B07F8E1A680F5B00A75B9A /* Resources */, + 00DD1BFF1BD5951E006B06BC /* Bundle React Native code and images */, + AF7BF8C823CE838DA4182532 /* [CP] Copy Pods Resources */, + 6ADAD6F126493A420001F56E /* Embed App Extensions */, + ); + buildRules = ( + ); + dependencies = ( + 6ADAD6EC26493A420001F56E /* PBXTargetDependency */, + ); + name = multitarget; + productName = multitarget; + productReference = 13B07F961A680F5B00A75B9A /* multitarget.app */; + productType = "com.apple.product-type.application"; + }; + 6ADAD6E126493A420001F56E /* shareextension */ = { + isa = PBXNativeTarget; + buildConfigurationList = 6ADAD6EE26493A420001F56E /* Build configuration list for PBXNativeTarget "shareextension" */; + buildPhases = ( + 6ADAD6DE26493A420001F56E /* Sources */, + 6ADAD6DF26493A420001F56E /* Frameworks */, + 6ADAD6E026493A420001F56E /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = shareextension; + productName = shareextension; + productReference = 6ADAD6E226493A420001F56E /* shareextension.appex */; + productType = "com.apple.product-type.app-extension"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 83CBB9F71A601CBA00E9B192 /* Project object */ = { + isa = PBXProject; + attributes = { + LastUpgradeCheck = 1130; + TargetAttributes = { + 13B07F861A680F5B00A75B9A = { + DevelopmentTeam = "ABCDEFGH"; + LastSwiftMigration = 1120; + ProvisioningStyle = Manual; + }; + 6ADAD6E126493A420001F56E = { + CreatedOnToolsVersion = 12.5; + DevelopmentTeam = "ABCDEFGH"; + ProvisioningStyle = Manual; + }; + }; + }; + buildConfigurationList = 83CBB9FA1A601CBA00E9B192 /* Build configuration list for PBXProject "multitarget" */; + compatibilityVersion = "Xcode 3.2"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = 83CBB9F61A601CBA00E9B192; + productRefGroup = 83CBBA001A601CBA00E9B192 /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 13B07F861A680F5B00A75B9A /* multitarget */, + 6ADAD6E126493A420001F56E /* shareextension */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 13B07F8E1A680F5B00A75B9A /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + BB2F792D24A3F905000567C9 /* Expo.plist in Resources */, + 13B07FBF1A68108700A75B9A /* Images.xcassets in Resources */, + 13B07FBD1A68108700A75B9A /* LaunchScreen.xib in Resources */, + 3E461D99554A48A4959DE609 /* SplashScreen.storyboard in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 6ADAD6E026493A420001F56E /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 6ADAD6E926493A420001F56E /* MainInterface.storyboard in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXShellScriptBuildPhase section */ + 00DD1BFF1BD5951E006B06BC /* Bundle React Native code and images */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + ); + name = "Bundle React Native code and images"; + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "if [[ -f /Users/dsokal/.volta/bin/node ]]; then\\n export NODE_BINARY=/Users/dsokal/.volta/bin/node\\nelse\\n export NODE_BINARY=node\\nfi\\n../node_modules/react-native/scripts/react-native-xcode.sh\\n../node_modules/expo-constants/scripts/get-app-config-ios.sh\\n../node_modules/expo-updates/scripts/create-manifest-ios.sh\\n"; + }; + 08A4A3CD28434E44B6B9DE2E /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "\${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "\${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-multitarget-checkManifestLockResult.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "diff \\"\${PODS_PODFILE_DIR_PATH}/Podfile.lock\\" \\"\${PODS_ROOT}/Manifest.lock\\" > /dev/null\\nif [ $? != 0 ] ; then\\n # print error to STDERR\\n echo \\"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\\" >&2\\n exit 1\\nfi\\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\\necho \\"SUCCESS\\" > \\"\${SCRIPT_OUTPUT_FILE_0}\\"\\n"; + showEnvVarsInLog = 0; + }; + AF7BF8C823CE838DA4182532 /* [CP] Copy Pods Resources */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + "\${PODS_ROOT}/Target Support Files/Pods-multitarget/Pods-multitarget-resources.sh", + "\${PODS_CONFIGURATION_BUILD_DIR}/React-Core/AccessibilityResources.bundle", + ); + name = "[CP] Copy Pods Resources"; + outputPaths = ( + "\${TARGET_BUILD_DIR}/\${UNLOCALIZED_RESOURCES_FOLDER_PATH}/AccessibilityResources.bundle", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\\"\${PODS_ROOT}/Target Support Files/Pods-multitarget/Pods-multitarget-resources.sh\\"\\n"; + showEnvVarsInLog = 0; + }; + FD10A7F022414F080027D42C /* Start Packager */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + ); + name = "Start Packager"; + outputFileListPaths = ( + ); + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "export RCT_METRO_PORT=\\"\${RCT_METRO_PORT:=8081}\\"\\necho \\"export RCT_METRO_PORT=\${RCT_METRO_PORT}\\" > \\"\${SRCROOT}/../node_modules/react-native/scripts/.packager.env\\"\\nif [ -z \\"\${RCT_NO_LAUNCH_PACKAGER+xxx}\\" ] ; then\\n if nc -w 5 -z localhost \${RCT_METRO_PORT} ; then\\n if ! curl -s \\"http://localhost:\${RCT_METRO_PORT}/status\\" | grep -q \\"packager-status:running\\" ; then\\n echo \\"Port \${RCT_METRO_PORT} already in use, packager is either not running or not running correctly\\"\\n exit 2\\n fi\\n else\\n open \\"$SRCROOT/../node_modules/react-native/scripts/launchPackager.command\\" || echo \\"Can't start packager automatically\\"\\n fi\\nfi\\n"; + showEnvVarsInLog = 0; + }; +/* End PBXShellScriptBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 13B07F871A680F5B00A75B9A /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 13B07FBC1A68108700A75B9A /* AppDelegate.m in Sources */, + 13B07FC11A68108700A75B9A /* main.m in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 6ADAD6DE26493A420001F56E /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 6ADAD6E626493A420001F56E /* ShareViewController.m in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXTargetDependency section */ + 6ADAD6EC26493A420001F56E /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 6ADAD6E126493A420001F56E /* shareextension */; + targetProxy = 6ADAD6EB26493A420001F56E /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + +/* Begin PBXVariantGroup section */ + 13B07FB11A68108700A75B9A /* LaunchScreen.xib */ = { + isa = PBXVariantGroup; + children = ( + 13B07FB21A68108700A75B9A /* Base */, + ); + name = LaunchScreen.xib; + path = multitarget; + sourceTree = ""; + }; + 6ADAD6E726493A420001F56E /* MainInterface.storyboard */ = { + isa = PBXVariantGroup; + children = ( + 6ADAD6E826493A420001F56E /* Base */, + ); + name = MainInterface.storyboard; + sourceTree = ""; + }; +/* End PBXVariantGroup section */ + +/* Begin XCBuildConfiguration section */ + 13B07F941A680F5B00A75B9A /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 6C2E3173556A471DD304B334 /* Pods-multitarget.debug.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = QL76XYH73P; + ENABLE_BITCODE = NO; + GCC_PREPROCESSOR_DEFINITIONS = ( + "$(inherited)", + "FB_SONARKIT_ENABLED=1", + ); + INFOPLIST_FILE = multitarget/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 11.0; + LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; + OTHER_LDFLAGS = ( + "$(inherited)", + "-ObjC", + "-lc++", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.swmansion.dominik.multitarget.debug; + PRODUCT_NAME = multitarget; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = Debug; + }; + 13B07F951A680F5B00A75B9A /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7A4D352CD337FB3A3BF06240 /* Pods-multitarget.release.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_IDENTITY = "iPhone Distribution"; + CODE_SIGN_STYLE = Manual; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = "ABCDEFGH"; + INFOPLIST_FILE = multitarget/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 11.0; + LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; + OTHER_LDFLAGS = ( + "$(inherited)", + "-ObjC", + "-lc++", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.swmansion.dominik.multitarget; + PRODUCT_NAME = multitarget; + PROVISIONING_PROFILE_SPECIFIER = "multitarget profile"; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = Release; + }; + 6ADAD6EF26493A420001F56E /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CODE_SIGN_STYLE = Automatic; + DEBUG_INFORMATION_FORMAT = dwarf; + DEVELOPMENT_TEAM = QL76XYH73P; + GCC_C_LANGUAGE_STANDARD = gnu11; + INFOPLIST_FILE = shareextension/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 14.5; + LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @executable_path/../../Frameworks"; + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + MTL_FAST_MATH = YES; + PRODUCT_BUNDLE_IDENTIFIER = com.swmansion.dominik.multitarget.shareextension; + PRODUCT_NAME = "$(TARGET_NAME)"; + SKIP_INSTALL = YES; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + 6ADAD6F026493A420001F56E /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CODE_SIGN_STYLE = Manual; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + DEVELOPMENT_TEAM = "ABCDEFGH"; + GCC_C_LANGUAGE_STANDARD = gnu11; + INFOPLIST_FILE = shareextension/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 14.5; + LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @executable_path/../../Frameworks"; + MTL_FAST_MATH = YES; + PRODUCT_BUNDLE_IDENTIFIER = com.swmansion.dominik.multitarget.shareextension; + PRODUCT_NAME = "$(TARGET_NAME)"; + SKIP_INSTALL = YES; + TARGETED_DEVICE_FAMILY = "1,2"; + PROVISIONING_PROFILE_SPECIFIER = "extension profile"; + CODE_SIGN_IDENTITY = "iPhone Distribution"; + }; + name = Release; + }; + 83CBBA201A601CBA00E9B192 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_SYMBOLS_PRIVATE_EXTERN = NO; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 11.0; + LD_RUNPATH_SEARCH_PATHS = "/usr/lib/swift $(inherited)"; + LIBRARY_SEARCH_PATHS = ( + "\\"$(TOOLCHAIN_DIR)/usr/lib/swift/$(PLATFORM_NAME)\\"", + "\\"$(TOOLCHAIN_DIR)/usr/lib/swift-5.0/$(PLATFORM_NAME)\\"", + "\\"$(inherited)\\"", + ); + MTL_ENABLE_DEBUG_INFO = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = iphoneos; + }; + name = Debug; + }; + 83CBBA211A601CBA00E9B192 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = YES; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 11.0; + LD_RUNPATH_SEARCH_PATHS = "/usr/lib/swift $(inherited)"; + LIBRARY_SEARCH_PATHS = ( + "\\"$(TOOLCHAIN_DIR)/usr/lib/swift/$(PLATFORM_NAME)\\"", + "\\"$(TOOLCHAIN_DIR)/usr/lib/swift-5.0/$(PLATFORM_NAME)\\"", + "\\"$(inherited)\\"", + ); + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = iphoneos; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 13B07F931A680F5B00A75B9A /* Build configuration list for PBXNativeTarget "multitarget" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 13B07F941A680F5B00A75B9A /* Debug */, + 13B07F951A680F5B00A75B9A /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 6ADAD6EE26493A420001F56E /* Build configuration list for PBXNativeTarget "shareextension" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 6ADAD6EF26493A420001F56E /* Debug */, + 6ADAD6F026493A420001F56E /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 83CBB9FA1A601CBA00E9B192 /* Build configuration list for PBXProject "multitarget" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 83CBBA201A601CBA00E9B192 /* Debug */, + 83CBBA211A601CBA00E9B192 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + }; + rootObject = 83CBB9F71A601CBA00E9B192 /* Project object */; +} +" +`; + +exports[`configureXcodeProject configures credentials and versions for multi target project: Info.plist application target 1`] = ` +" + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + testapp + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + $(PRODUCT_BUNDLE_PACKAGE_TYPE) + CFBundleShortVersionString + 1.2.3 + CFBundleSignature + ???? + CFBundleURLTypes + + + CFBundleURLSchemes + + org.reactjs.native.example.testapp.turtlev2 + + + + CFBundleVersion + 1.2.4 + LSRequiresIPhoneOS + + NSAppTransportSecurity + + NSAllowsArbitraryLoads + + NSExceptionDomains + + localhost + + NSExceptionAllowsInsecureHTTPLoads + + + + + UILaunchStoryboardName + SplashScreen + UIRequiredDeviceCapabilities + + armv7 + + UIRequiresFullScreen + + UIStatusBarStyle + UIStatusBarStyleDefault + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + + UISupportedInterfaceOrientations~ipad + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UIUserInterfaceStyle + Light + UIViewControllerBasedStatusBarAppearance + + +" +`; + +exports[`configureXcodeProject configures credentials and versions for multi target project: Info.plist extension 1`] = ` +" + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + testapp + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + $(PRODUCT_BUNDLE_PACKAGE_TYPE) + CFBundleShortVersionString + 1.2.3 + CFBundleSignature + ???? + CFBundleURLTypes + + + CFBundleURLSchemes + + org.reactjs.native.example.testapp.turtlev2 + + + + CFBundleVersion + 1.2.4 + LSRequiresIPhoneOS + + NSAppTransportSecurity + + NSAllowsArbitraryLoads + + NSExceptionDomains + + localhost + + NSExceptionAllowsInsecureHTTPLoads + + + + + UILaunchStoryboardName + SplashScreen + UIRequiredDeviceCapabilities + + armv7 + + UIRequiresFullScreen + + UIStatusBarStyle + UIStatusBarStyleDefault + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + + UISupportedInterfaceOrientations~ipad + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UIUserInterfaceStyle + Light + UIViewControllerBasedStatusBarAppearance + + +" +`; + +exports[`configureXcodeProject configures credentials for a simple project 1`] = ` +"// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 46; + objects = { + +/* Begin PBXBuildFile section */ + 13B07FBC1A68108700A75B9A /* AppDelegate.m in Sources */ = {isa = PBXBuildFile; fileRef = 13B07FB01A68108700A75B9A /* AppDelegate.m */; }; + 13B07FBF1A68108700A75B9A /* Images.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 13B07FB51A68108700A75B9A /* Images.xcassets */; }; + 13B07FC11A68108700A75B9A /* main.m in Sources */ = {isa = PBXBuildFile; fileRef = 13B07FB71A68108700A75B9A /* main.m */; }; + 3E461D99554A48A4959DE609 /* SplashScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = AA286B85B6C04FC6940260E9 /* SplashScreen.storyboard */; }; + 96905EF65AED1B983A6B3ABC /* libPods-testapp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 58EEBF8E8E6FB1BC6CAF49B5 /* libPods-testapp.a */; }; + B18059E884C0ABDD17F3DC3D /* ExpoModulesProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = FAC715A2D49A985799AEE119 /* ExpoModulesProvider.swift */; }; + BB2F792D24A3F905000567C9 /* Expo.plist in Resources */ = {isa = PBXBuildFile; fileRef = BB2F792C24A3F905000567C9 /* Expo.plist */; }; + E112987CA504453081AC7CD8 /* noop-file.swift in Sources */ = {isa = PBXBuildFile; fileRef = C911AA20BDD841449D1CE041 /* noop-file.swift */; }; +/* End PBXBuildFile section */ + +/* Begin PBXFileReference section */ + 008F07F21AC5B25A0029DE68 /* main.jsbundle */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text; path = main.jsbundle; sourceTree = ""; }; + 13B07F961A680F5B00A75B9A /* testapp.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = testapp.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 13B07FAF1A68108700A75B9A /* AppDelegate.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; name = AppDelegate.h; path = testapp/AppDelegate.h; sourceTree = ""; }; + 13B07FB01A68108700A75B9A /* AppDelegate.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; name = AppDelegate.m; path = testapp/AppDelegate.m; sourceTree = ""; }; + 13B07FB51A68108700A75B9A /* Images.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; name = Images.xcassets; path = testapp/Images.xcassets; sourceTree = ""; }; + 13B07FB61A68108700A75B9A /* Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = Info.plist; path = testapp/Info.plist; sourceTree = ""; }; + 13B07FB71A68108700A75B9A /* main.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; name = main.m; path = testapp/main.m; sourceTree = ""; }; + 58EEBF8E8E6FB1BC6CAF49B5 /* libPods-testapp.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = "libPods-testapp.a"; sourceTree = BUILT_PRODUCTS_DIR; }; + 6C2E3173556A471DD304B334 /* Pods-testapp.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-testapp.debug.xcconfig"; path = "Target Support Files/Pods-testapp/Pods-testapp.debug.xcconfig"; sourceTree = ""; }; + 7A4D352CD337FB3A3BF06240 /* Pods-testapp.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-testapp.release.xcconfig"; path = "Target Support Files/Pods-testapp/Pods-testapp.release.xcconfig"; sourceTree = ""; }; + AA286B85B6C04FC6940260E9 /* SplashScreen.storyboard */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.storyboard; name = SplashScreen.storyboard; path = testapp/SplashScreen.storyboard; sourceTree = ""; }; + BB2F792C24A3F905000567C9 /* Expo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = Expo.plist; sourceTree = ""; }; + ED297162215061F000B7C4FE /* JavaScriptCore.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = JavaScriptCore.framework; path = System/Library/Frameworks/JavaScriptCore.framework; sourceTree = SDKROOT; }; + FAC715A2D49A985799AEE119 /* ExpoModulesProvider.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = ExpoModulesProvider.swift; path = "Pods/Target Support Files/Pods-testapp/ExpoModulesProvider.swift"; sourceTree = ""; }; + C911AA20BDD841449D1CE041 /* noop-file.swift */ = {isa = PBXFileReference; name = "noop-file.swift"; path = "testapp/noop-file.swift"; sourceTree = ""; fileEncoding = 4; lastKnownFileType = sourcecode.swift; explicitFileType = undefined; includeInIndex = 0; }; + 0962172184234A4793928022 /* testapp-Bridging-Header.h */ = {isa = PBXFileReference; name = "testapp-Bridging-Header.h"; path = "testapp/testapp-Bridging-Header.h"; sourceTree = ""; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; explicitFileType = undefined; includeInIndex = 0; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + 13B07F8C1A680F5B00A75B9A /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 96905EF65AED1B983A6B3ABC /* libPods-testapp.a in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 13B07FAE1A68108700A75B9A /* testapp */ = { + isa = PBXGroup; + children = ( + BB2F792B24A3F905000567C9 /* Supporting */, + 008F07F21AC5B25A0029DE68 /* main.jsbundle */, + 13B07FAF1A68108700A75B9A /* AppDelegate.h */, + 13B07FB01A68108700A75B9A /* AppDelegate.m */, + 13B07FB51A68108700A75B9A /* Images.xcassets */, + 13B07FB61A68108700A75B9A /* Info.plist */, + 13B07FB71A68108700A75B9A /* main.m */, + AA286B85B6C04FC6940260E9 /* SplashScreen.storyboard */, + C911AA20BDD841449D1CE041 /* noop-file.swift */, + 0962172184234A4793928022 /* testapp-Bridging-Header.h */, + ); + name = testapp; + sourceTree = ""; + }; + 2D16E6871FA4F8E400B85C8A /* Frameworks */ = { + isa = PBXGroup; + children = ( + ED297162215061F000B7C4FE /* JavaScriptCore.framework */, + 58EEBF8E8E6FB1BC6CAF49B5 /* libPods-testapp.a */, + ); + name = Frameworks; + sourceTree = ""; + }; + 832341AE1AAA6A7D00B99B32 /* Libraries */ = { + isa = PBXGroup; + children = ( + ); + name = Libraries; + sourceTree = ""; + }; + 83CBB9F61A601CBA00E9B192 = { + isa = PBXGroup; + children = ( + 13B07FAE1A68108700A75B9A /* testapp */, + 832341AE1AAA6A7D00B99B32 /* Libraries */, + 83CBBA001A601CBA00E9B192 /* Products */, + 2D16E6871FA4F8E400B85C8A /* Frameworks */, + D65327D7A22EEC0BE12398D9 /* Pods */, + D7E4C46ADA2E9064B798F356 /* ExpoModulesProviders */, + ); + indentWidth = 2; + sourceTree = ""; + tabWidth = 2; + usesTabs = 0; + }; + 83CBBA001A601CBA00E9B192 /* Products */ = { + isa = PBXGroup; + children = ( + 13B07F961A680F5B00A75B9A /* testapp.app */, + ); + name = Products; + sourceTree = ""; + }; + 92DBD88DE9BF7D494EA9DA96 /* testapp */ = { + isa = PBXGroup; + children = ( + FAC715A2D49A985799AEE119 /* ExpoModulesProvider.swift */, + ); + name = testapp; + sourceTree = ""; + }; + BB2F792B24A3F905000567C9 /* Supporting */ = { + isa = PBXGroup; + children = ( + BB2F792C24A3F905000567C9 /* Expo.plist */, + ); + name = Supporting; + path = testapp/Supporting; + sourceTree = ""; + }; + D65327D7A22EEC0BE12398D9 /* Pods */ = { + isa = PBXGroup; + children = ( + 6C2E3173556A471DD304B334 /* Pods-testapp.debug.xcconfig */, + 7A4D352CD337FB3A3BF06240 /* Pods-testapp.release.xcconfig */, + ); + path = Pods; + sourceTree = ""; + }; + D7E4C46ADA2E9064B798F356 /* ExpoModulesProviders */ = { + isa = PBXGroup; + children = ( + 92DBD88DE9BF7D494EA9DA96 /* testapp */, + ); + name = ExpoModulesProviders; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 13B07F861A680F5B00A75B9A /* testapp */ = { + isa = PBXNativeTarget; + buildConfigurationList = 13B07F931A680F5B00A75B9A /* Build configuration list for PBXNativeTarget "testapp" */; + buildPhases = ( + 08A4A3CD28434E44B6B9DE2E /* [CP] Check Pods Manifest.lock */, + FD10A7F022414F080027D42C /* Start Packager */, + 13B07F871A680F5B00A75B9A /* Sources */, + 13B07F8C1A680F5B00A75B9A /* Frameworks */, + 13B07F8E1A680F5B00A75B9A /* Resources */, + 00DD1BFF1BD5951E006B06BC /* Bundle React Native code and images */, + 800E24972A6A228C8D4807E9 /* [CP] Copy Pods Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = testapp; + productName = testapp; + productReference = 13B07F961A680F5B00A75B9A /* testapp.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 83CBB9F71A601CBA00E9B192 /* Project object */ = { + isa = PBXProject; + attributes = { + LastUpgradeCheck = 1130; + TargetAttributes = { + 13B07F861A680F5B00A75B9A = { + LastSwiftMigration = 1250; + DevelopmentTeam = "ABCDEFGH"; + ProvisioningStyle = Manual; + }; + }; + }; + buildConfigurationList = 83CBB9FA1A601CBA00E9B192 /* Build configuration list for PBXProject "testapp" */; + compatibilityVersion = "Xcode 3.2"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = 83CBB9F61A601CBA00E9B192; + productRefGroup = 83CBBA001A601CBA00E9B192 /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 13B07F861A680F5B00A75B9A /* testapp */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 13B07F8E1A680F5B00A75B9A /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + BB2F792D24A3F905000567C9 /* Expo.plist in Resources */, + 13B07FBF1A68108700A75B9A /* Images.xcassets in Resources */, + 3E461D99554A48A4959DE609 /* SplashScreen.storyboard in Resources */, + C9A08D1C39C94E5D824DFA3F /* testapp-Bridging-Header.h in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXShellScriptBuildPhase section */ + 00DD1BFF1BD5951E006B06BC /* Bundle React Native code and images */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + ); + name = "Bundle React Native code and images"; + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "export NODE_BINARY=node\\n\\n# The project root by default is one level up from the ios directory\\nexport PROJECT_ROOT=\\"$PROJECT_DIR\\"/..\\n\\n\`node --print \\"require('path').dirname(require.resolve('react-native/package.json')) + '/scripts/react-native-xcode.sh'\\"\`\\n"; + }; + 08A4A3CD28434E44B6B9DE2E /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "\${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "\${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-testapp-checkManifestLockResult.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "diff \\"\${PODS_PODFILE_DIR_PATH}/Podfile.lock\\" \\"\${PODS_ROOT}/Manifest.lock\\" > /dev/null\\nif [ $? != 0 ] ; then\\n # print error to STDERR\\n echo \\"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\\" >&2\\n exit 1\\nfi\\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\\necho \\"SUCCESS\\" > \\"\${SCRIPT_OUTPUT_FILE_0}\\"\\n"; + showEnvVarsInLog = 0; + }; + 800E24972A6A228C8D4807E9 /* [CP] Copy Pods Resources */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + "\${PODS_ROOT}/Target Support Files/Pods-testapp/Pods-testapp-resources.sh", + "\${PODS_CONFIGURATION_BUILD_DIR}/EXConstants/EXConstants.bundle", + "\${PODS_CONFIGURATION_BUILD_DIR}/EXUpdates/EXUpdates.bundle", + "\${PODS_CONFIGURATION_BUILD_DIR}/React-Core/AccessibilityResources.bundle", + ); + name = "[CP] Copy Pods Resources"; + outputPaths = ( + "\${TARGET_BUILD_DIR}/\${UNLOCALIZED_RESOURCES_FOLDER_PATH}/EXConstants.bundle", + "\${TARGET_BUILD_DIR}/\${UNLOCALIZED_RESOURCES_FOLDER_PATH}/EXUpdates.bundle", + "\${TARGET_BUILD_DIR}/\${UNLOCALIZED_RESOURCES_FOLDER_PATH}/AccessibilityResources.bundle", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\\"\${PODS_ROOT}/Target Support Files/Pods-testapp/Pods-testapp-resources.sh\\"\\n"; + showEnvVarsInLog = 0; + }; + FD10A7F022414F080027D42C /* Start Packager */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + ); + name = "Start Packager"; + outputFileListPaths = ( + ); + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "export RCT_METRO_PORT=\\"\${RCT_METRO_PORT:=8081}\\"\\necho \\"export RCT_METRO_PORT=\${RCT_METRO_PORT}\\" > \`node --print \\"require('path').dirname(require.resolve('react-native/package.json')) + '/scripts/.packager.env'\\"\`\\nif [ -z \\"\${RCT_NO_LAUNCH_PACKAGER+xxx}\\" ] ; then\\n if nc -w 5 -z localhost \${RCT_METRO_PORT} ; then\\n if ! curl -s \\"http://localhost:\${RCT_METRO_PORT}/status\\" | grep -q \\"packager-status:running\\" ; then\\n echo \\"Port \${RCT_METRO_PORT} already in use, packager is either not running or not running correctly\\"\\n exit 2\\n fi\\n else\\n open \`node --print \\"require('path').dirname(require.resolve('react-native/package.json')) + '/scripts/launchPackager.command'\\"\` || echo \\"Can't start packager automatically\\"\\n fi\\nfi\\n"; + showEnvVarsInLog = 0; + }; +/* End PBXShellScriptBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 13B07F871A680F5B00A75B9A /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 13B07FBC1A68108700A75B9A /* AppDelegate.m in Sources */, + 13B07FC11A68108700A75B9A /* main.m in Sources */, + B18059E884C0ABDD17F3DC3D /* ExpoModulesProvider.swift in Sources */, + E112987CA504453081AC7CD8 /* noop-file.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin XCBuildConfiguration section */ + 13B07F941A680F5B00A75B9A /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 6C2E3173556A471DD304B334 /* Pods-testapp.debug.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CURRENT_PROJECT_VERSION = 1; + ENABLE_BITCODE = NO; + GCC_PREPROCESSOR_DEFINITIONS = ( + "$(inherited)", + "FB_SONARKIT_ENABLED=1", + ); + INFOPLIST_FILE = testapp/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 12.0; + LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; + OTHER_LDFLAGS = ( + "$(inherited)", + "-ObjC", + "-lc++", + ); + PRODUCT_BUNDLE_IDENTIFIER = "org.reactjs.native.example.testapp.turtlev2"; + PRODUCT_NAME = turtlev2; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + TARGETED_DEVICE_FAMILY = "1,2"; + SWIFT_OBJC_BRIDGING_HEADER = testapp/testapp-Bridging-Header.h; + CODE_SIGN_ENTITLEMENTS = testapp/turtlev2.entitlements; + }; + name = Debug; + }; + 13B07F951A680F5B00A75B9A /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7A4D352CD337FB3A3BF06240 /* Pods-testapp.release.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CURRENT_PROJECT_VERSION = 1; + INFOPLIST_FILE = testapp/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 12.0; + LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; + OTHER_LDFLAGS = ( + "$(inherited)", + "-ObjC", + "-lc++", + ); + PRODUCT_BUNDLE_IDENTIFIER = "org.reactjs.native.example.testapp.turtlev2"; + PRODUCT_NAME = turtlev2; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + TARGETED_DEVICE_FAMILY = "1,2"; + SWIFT_OBJC_BRIDGING_HEADER = testapp/testapp-Bridging-Header.h; + CODE_SIGN_ENTITLEMENTS = testapp/turtlev2.entitlements; + PROVISIONING_PROFILE_SPECIFIER = "profile name"; + DEVELOPMENT_TEAM = "ABCDEFGH"; + CODE_SIGN_IDENTITY = "iPhone Distribution"; + CODE_SIGN_STYLE = Manual; + }; + name = Release; + }; + 83CBBA201A601CBA00E9B192 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_SYMBOLS_PRIVATE_EXTERN = NO; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 12.0; + LD_RUNPATH_SEARCH_PATHS = "/usr/lib/swift $(inherited)"; + LIBRARY_SEARCH_PATHS = "\\"$(inherited)\\""; + MTL_ENABLE_DEBUG_INFO = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = iphoneos; + CODE_SIGN_ENTITLEMENTS = testapp/turtlev2.entitlements; + }; + name = Debug; + }; + 83CBBA211A601CBA00E9B192 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = YES; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 12.0; + LD_RUNPATH_SEARCH_PATHS = "/usr/lib/swift $(inherited)"; + LIBRARY_SEARCH_PATHS = "\\"$(inherited)\\""; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = iphoneos; + VALIDATE_PRODUCT = YES; + CODE_SIGN_ENTITLEMENTS = testapp/turtlev2.entitlements; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 13B07F931A680F5B00A75B9A /* Build configuration list for PBXNativeTarget "testapp" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 13B07F941A680F5B00A75B9A /* Debug */, + 13B07F951A680F5B00A75B9A /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 83CBB9FA1A601CBA00E9B192 /* Build configuration list for PBXProject "testapp" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 83CBBA201A601CBA00E9B192 /* Debug */, + 83CBBA211A601CBA00E9B192 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + }; + rootObject = 83CBB9F71A601CBA00E9B192 /* Project object */; +} +" +`; + +exports[`configureXcodeProject configures credentials for multi target project 1`] = ` +"// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 46; + objects = { + +/* Begin PBXBuildFile section */ + 13B07FBC1A68108700A75B9A /* AppDelegate.m in Sources */ = {isa = PBXBuildFile; fileRef = 13B07FB01A68108700A75B9A /* AppDelegate.m */; }; + 13B07FBD1A68108700A75B9A /* LaunchScreen.xib in Resources */ = {isa = PBXBuildFile; fileRef = 13B07FB11A68108700A75B9A /* LaunchScreen.xib */; }; + 13B07FBF1A68108700A75B9A /* Images.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 13B07FB51A68108700A75B9A /* Images.xcassets */; }; + 13B07FC11A68108700A75B9A /* main.m in Sources */ = {isa = PBXBuildFile; fileRef = 13B07FB71A68108700A75B9A /* main.m */; }; + 3E461D99554A48A4959DE609 /* SplashScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = AA286B85B6C04FC6940260E9 /* SplashScreen.storyboard */; }; + 6ADAD6E626493A420001F56E /* ShareViewController.m in Sources */ = {isa = PBXBuildFile; fileRef = 6ADAD6E526493A420001F56E /* ShareViewController.m */; }; + 6ADAD6E926493A420001F56E /* MainInterface.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 6ADAD6E726493A420001F56E /* MainInterface.storyboard */; }; + 6ADAD6ED26493A420001F56E /* shareextension.appex in Embed App Extensions */ = {isa = PBXBuildFile; fileRef = 6ADAD6E226493A420001F56E /* shareextension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; + 96905EF65AED1B983A6B3ABC /* libPods-multitarget.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 58EEBF8E8E6FB1BC6CAF49B5 /* libPods-multitarget.a */; }; + BB2F792D24A3F905000567C9 /* Expo.plist in Resources */ = {isa = PBXBuildFile; fileRef = BB2F792C24A3F905000567C9 /* Expo.plist */; }; +/* End PBXBuildFile section */ + +/* Begin PBXContainerItemProxy section */ + 6ADAD6EB26493A420001F56E /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 83CBB9F71A601CBA00E9B192 /* Project object */; + proxyType = 1; + remoteGlobalIDString = 6ADAD6E126493A420001F56E; + remoteInfo = shareextension; + }; +/* End PBXContainerItemProxy section */ + +/* Begin PBXCopyFilesBuildPhase section */ + 6ADAD6F126493A420001F56E /* Embed App Extensions */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 13; + files = ( + 6ADAD6ED26493A420001F56E /* shareextension.appex in Embed App Extensions */, + ); + name = "Embed App Extensions"; + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXCopyFilesBuildPhase section */ + +/* Begin PBXFileReference section */ + 008F07F21AC5B25A0029DE68 /* main.jsbundle */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text; path = main.jsbundle; sourceTree = ""; }; + 13B07F961A680F5B00A75B9A /* multitarget.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = multitarget.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 13B07FAF1A68108700A75B9A /* AppDelegate.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; name = AppDelegate.h; path = multitarget/AppDelegate.h; sourceTree = ""; }; + 13B07FB01A68108700A75B9A /* AppDelegate.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; name = AppDelegate.m; path = multitarget/AppDelegate.m; sourceTree = ""; }; + 13B07FB21A68108700A75B9A /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.xib; name = Base; path = Base.lproj/LaunchScreen.xib; sourceTree = ""; }; + 13B07FB51A68108700A75B9A /* Images.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; name = Images.xcassets; path = multitarget/Images.xcassets; sourceTree = ""; }; + 13B07FB61A68108700A75B9A /* Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = Info.plist; path = multitarget/Info.plist; sourceTree = ""; }; + 13B07FB71A68108700A75B9A /* main.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; name = main.m; path = multitarget/main.m; sourceTree = ""; }; + 58EEBF8E8E6FB1BC6CAF49B5 /* libPods-multitarget.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = "libPods-multitarget.a"; sourceTree = BUILT_PRODUCTS_DIR; }; + 6ADAD6E226493A420001F56E /* shareextension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = shareextension.appex; sourceTree = BUILT_PRODUCTS_DIR; }; + 6ADAD6E426493A420001F56E /* ShareViewController.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = ShareViewController.h; sourceTree = ""; }; + 6ADAD6E526493A420001F56E /* ShareViewController.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = ShareViewController.m; sourceTree = ""; }; + 6ADAD6E826493A420001F56E /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/MainInterface.storyboard; sourceTree = ""; }; + 6ADAD6EA26493A420001F56E /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + 6C2E3173556A471DD304B334 /* Pods-multitarget.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-multitarget.debug.xcconfig"; path = "Target Support Files/Pods-multitarget/Pods-multitarget.debug.xcconfig"; sourceTree = ""; }; + 7A4D352CD337FB3A3BF06240 /* Pods-multitarget.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-multitarget.release.xcconfig"; path = "Target Support Files/Pods-multitarget/Pods-multitarget.release.xcconfig"; sourceTree = ""; }; + AA286B85B6C04FC6940260E9 /* SplashScreen.storyboard */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.storyboard; name = SplashScreen.storyboard; path = multitarget/SplashScreen.storyboard; sourceTree = ""; }; + BB2F792C24A3F905000567C9 /* Expo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = Expo.plist; sourceTree = ""; }; + ED297162215061F000B7C4FE /* JavaScriptCore.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = JavaScriptCore.framework; path = System/Library/Frameworks/JavaScriptCore.framework; sourceTree = SDKROOT; }; + ED2971642150620600B7C4FE /* JavaScriptCore.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = JavaScriptCore.framework; path = Platforms/AppleTVOS.platform/Developer/SDKs/AppleTVOS12.0.sdk/System/Library/Frameworks/JavaScriptCore.framework; sourceTree = DEVELOPER_DIR; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + 13B07F8C1A680F5B00A75B9A /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 96905EF65AED1B983A6B3ABC /* libPods-multitarget.a in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 6ADAD6DF26493A420001F56E /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 13B07FAE1A68108700A75B9A /* multitarget */ = { + isa = PBXGroup; + children = ( + BB2F792B24A3F905000567C9 /* Supporting */, + 008F07F21AC5B25A0029DE68 /* main.jsbundle */, + 13B07FAF1A68108700A75B9A /* AppDelegate.h */, + 13B07FB01A68108700A75B9A /* AppDelegate.m */, + 13B07FB51A68108700A75B9A /* Images.xcassets */, + 13B07FB61A68108700A75B9A /* Info.plist */, + 13B07FB11A68108700A75B9A /* LaunchScreen.xib */, + 13B07FB71A68108700A75B9A /* main.m */, + AA286B85B6C04FC6940260E9 /* SplashScreen.storyboard */, + ); + name = multitarget; + sourceTree = ""; + }; + 2D16E6871FA4F8E400B85C8A /* Frameworks */ = { + isa = PBXGroup; + children = ( + ED297162215061F000B7C4FE /* JavaScriptCore.framework */, + ED2971642150620600B7C4FE /* JavaScriptCore.framework */, + 58EEBF8E8E6FB1BC6CAF49B5 /* libPods-multitarget.a */, + ); + name = Frameworks; + sourceTree = ""; + }; + 6ADAD6E326493A420001F56E /* shareextension */ = { + isa = PBXGroup; + children = ( + 6ADAD6E426493A420001F56E /* ShareViewController.h */, + 6ADAD6E526493A420001F56E /* ShareViewController.m */, + 6ADAD6E726493A420001F56E /* MainInterface.storyboard */, + 6ADAD6EA26493A420001F56E /* Info.plist */, + ); + path = shareextension; + sourceTree = ""; + }; + 832341AE1AAA6A7D00B99B32 /* Libraries */ = { + isa = PBXGroup; + children = ( + ); + name = Libraries; + sourceTree = ""; + }; + 83CBB9F61A601CBA00E9B192 = { + isa = PBXGroup; + children = ( + 13B07FAE1A68108700A75B9A /* multitarget */, + 832341AE1AAA6A7D00B99B32 /* Libraries */, + 6ADAD6E326493A420001F56E /* shareextension */, + 83CBBA001A601CBA00E9B192 /* Products */, + 2D16E6871FA4F8E400B85C8A /* Frameworks */, + D65327D7A22EEC0BE12398D9 /* Pods */, + ); + indentWidth = 2; + sourceTree = ""; + tabWidth = 2; + usesTabs = 0; + }; + 83CBBA001A601CBA00E9B192 /* Products */ = { + isa = PBXGroup; + children = ( + 13B07F961A680F5B00A75B9A /* multitarget.app */, + 6ADAD6E226493A420001F56E /* shareextension.appex */, + ); + name = Products; + sourceTree = ""; + }; + BB2F792B24A3F905000567C9 /* Supporting */ = { + isa = PBXGroup; + children = ( + BB2F792C24A3F905000567C9 /* Expo.plist */, + ); + name = Supporting; + path = multitarget/Supporting; + sourceTree = ""; + }; + D65327D7A22EEC0BE12398D9 /* Pods */ = { + isa = PBXGroup; + children = ( + 6C2E3173556A471DD304B334 /* Pods-multitarget.debug.xcconfig */, + 7A4D352CD337FB3A3BF06240 /* Pods-multitarget.release.xcconfig */, + ); + path = Pods; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 13B07F861A680F5B00A75B9A /* multitarget */ = { + isa = PBXNativeTarget; + buildConfigurationList = 13B07F931A680F5B00A75B9A /* Build configuration list for PBXNativeTarget "multitarget" */; + buildPhases = ( + 08A4A3CD28434E44B6B9DE2E /* [CP] Check Pods Manifest.lock */, + FD10A7F022414F080027D42C /* Start Packager */, + 13B07F871A680F5B00A75B9A /* Sources */, + 13B07F8C1A680F5B00A75B9A /* Frameworks */, + 13B07F8E1A680F5B00A75B9A /* Resources */, + 00DD1BFF1BD5951E006B06BC /* Bundle React Native code and images */, + AF7BF8C823CE838DA4182532 /* [CP] Copy Pods Resources */, + 6ADAD6F126493A420001F56E /* Embed App Extensions */, + ); + buildRules = ( + ); + dependencies = ( + 6ADAD6EC26493A420001F56E /* PBXTargetDependency */, + ); + name = multitarget; + productName = multitarget; + productReference = 13B07F961A680F5B00A75B9A /* multitarget.app */; + productType = "com.apple.product-type.application"; + }; + 6ADAD6E126493A420001F56E /* shareextension */ = { + isa = PBXNativeTarget; + buildConfigurationList = 6ADAD6EE26493A420001F56E /* Build configuration list for PBXNativeTarget "shareextension" */; + buildPhases = ( + 6ADAD6DE26493A420001F56E /* Sources */, + 6ADAD6DF26493A420001F56E /* Frameworks */, + 6ADAD6E026493A420001F56E /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = shareextension; + productName = shareextension; + productReference = 6ADAD6E226493A420001F56E /* shareextension.appex */; + productType = "com.apple.product-type.app-extension"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 83CBB9F71A601CBA00E9B192 /* Project object */ = { + isa = PBXProject; + attributes = { + LastUpgradeCheck = 1130; + TargetAttributes = { + 13B07F861A680F5B00A75B9A = { + DevelopmentTeam = "ABCDEFGH"; + LastSwiftMigration = 1120; + ProvisioningStyle = Manual; + }; + 6ADAD6E126493A420001F56E = { + CreatedOnToolsVersion = 12.5; + DevelopmentTeam = "ABCDEFGH"; + ProvisioningStyle = Manual; + }; + }; + }; + buildConfigurationList = 83CBB9FA1A601CBA00E9B192 /* Build configuration list for PBXProject "multitarget" */; + compatibilityVersion = "Xcode 3.2"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = 83CBB9F61A601CBA00E9B192; + productRefGroup = 83CBBA001A601CBA00E9B192 /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 13B07F861A680F5B00A75B9A /* multitarget */, + 6ADAD6E126493A420001F56E /* shareextension */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 13B07F8E1A680F5B00A75B9A /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + BB2F792D24A3F905000567C9 /* Expo.plist in Resources */, + 13B07FBF1A68108700A75B9A /* Images.xcassets in Resources */, + 13B07FBD1A68108700A75B9A /* LaunchScreen.xib in Resources */, + 3E461D99554A48A4959DE609 /* SplashScreen.storyboard in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 6ADAD6E026493A420001F56E /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 6ADAD6E926493A420001F56E /* MainInterface.storyboard in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXShellScriptBuildPhase section */ + 00DD1BFF1BD5951E006B06BC /* Bundle React Native code and images */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + ); + name = "Bundle React Native code and images"; + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "if [[ -f /Users/dsokal/.volta/bin/node ]]; then\\n export NODE_BINARY=/Users/dsokal/.volta/bin/node\\nelse\\n export NODE_BINARY=node\\nfi\\n../node_modules/react-native/scripts/react-native-xcode.sh\\n../node_modules/expo-constants/scripts/get-app-config-ios.sh\\n../node_modules/expo-updates/scripts/create-manifest-ios.sh\\n"; + }; + 08A4A3CD28434E44B6B9DE2E /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "\${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "\${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-multitarget-checkManifestLockResult.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "diff \\"\${PODS_PODFILE_DIR_PATH}/Podfile.lock\\" \\"\${PODS_ROOT}/Manifest.lock\\" > /dev/null\\nif [ $? != 0 ] ; then\\n # print error to STDERR\\n echo \\"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\\" >&2\\n exit 1\\nfi\\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\\necho \\"SUCCESS\\" > \\"\${SCRIPT_OUTPUT_FILE_0}\\"\\n"; + showEnvVarsInLog = 0; + }; + AF7BF8C823CE838DA4182532 /* [CP] Copy Pods Resources */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + "\${PODS_ROOT}/Target Support Files/Pods-multitarget/Pods-multitarget-resources.sh", + "\${PODS_CONFIGURATION_BUILD_DIR}/React-Core/AccessibilityResources.bundle", + ); + name = "[CP] Copy Pods Resources"; + outputPaths = ( + "\${TARGET_BUILD_DIR}/\${UNLOCALIZED_RESOURCES_FOLDER_PATH}/AccessibilityResources.bundle", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\\"\${PODS_ROOT}/Target Support Files/Pods-multitarget/Pods-multitarget-resources.sh\\"\\n"; + showEnvVarsInLog = 0; + }; + FD10A7F022414F080027D42C /* Start Packager */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + ); + name = "Start Packager"; + outputFileListPaths = ( + ); + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "export RCT_METRO_PORT=\\"\${RCT_METRO_PORT:=8081}\\"\\necho \\"export RCT_METRO_PORT=\${RCT_METRO_PORT}\\" > \\"\${SRCROOT}/../node_modules/react-native/scripts/.packager.env\\"\\nif [ -z \\"\${RCT_NO_LAUNCH_PACKAGER+xxx}\\" ] ; then\\n if nc -w 5 -z localhost \${RCT_METRO_PORT} ; then\\n if ! curl -s \\"http://localhost:\${RCT_METRO_PORT}/status\\" | grep -q \\"packager-status:running\\" ; then\\n echo \\"Port \${RCT_METRO_PORT} already in use, packager is either not running or not running correctly\\"\\n exit 2\\n fi\\n else\\n open \\"$SRCROOT/../node_modules/react-native/scripts/launchPackager.command\\" || echo \\"Can't start packager automatically\\"\\n fi\\nfi\\n"; + showEnvVarsInLog = 0; + }; +/* End PBXShellScriptBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 13B07F871A680F5B00A75B9A /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 13B07FBC1A68108700A75B9A /* AppDelegate.m in Sources */, + 13B07FC11A68108700A75B9A /* main.m in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 6ADAD6DE26493A420001F56E /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 6ADAD6E626493A420001F56E /* ShareViewController.m in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXTargetDependency section */ + 6ADAD6EC26493A420001F56E /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 6ADAD6E126493A420001F56E /* shareextension */; + targetProxy = 6ADAD6EB26493A420001F56E /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + +/* Begin PBXVariantGroup section */ + 13B07FB11A68108700A75B9A /* LaunchScreen.xib */ = { + isa = PBXVariantGroup; + children = ( + 13B07FB21A68108700A75B9A /* Base */, + ); + name = LaunchScreen.xib; + path = multitarget; + sourceTree = ""; + }; + 6ADAD6E726493A420001F56E /* MainInterface.storyboard */ = { + isa = PBXVariantGroup; + children = ( + 6ADAD6E826493A420001F56E /* Base */, + ); + name = MainInterface.storyboard; + sourceTree = ""; + }; +/* End PBXVariantGroup section */ + +/* Begin XCBuildConfiguration section */ + 13B07F941A680F5B00A75B9A /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 6C2E3173556A471DD304B334 /* Pods-multitarget.debug.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = QL76XYH73P; + ENABLE_BITCODE = NO; + GCC_PREPROCESSOR_DEFINITIONS = ( + "$(inherited)", + "FB_SONARKIT_ENABLED=1", + ); + INFOPLIST_FILE = multitarget/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 11.0; + LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; + OTHER_LDFLAGS = ( + "$(inherited)", + "-ObjC", + "-lc++", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.swmansion.dominik.multitarget.debug; + PRODUCT_NAME = multitarget; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = Debug; + }; + 13B07F951A680F5B00A75B9A /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7A4D352CD337FB3A3BF06240 /* Pods-multitarget.release.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_IDENTITY = "iPhone Distribution"; + CODE_SIGN_STYLE = Manual; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = "ABCDEFGH"; + INFOPLIST_FILE = multitarget/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 11.0; + LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; + OTHER_LDFLAGS = ( + "$(inherited)", + "-ObjC", + "-lc++", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.swmansion.dominik.multitarget; + PRODUCT_NAME = multitarget; + PROVISIONING_PROFILE_SPECIFIER = "multitarget profile"; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = Release; + }; + 6ADAD6EF26493A420001F56E /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CODE_SIGN_STYLE = Automatic; + DEBUG_INFORMATION_FORMAT = dwarf; + DEVELOPMENT_TEAM = QL76XYH73P; + GCC_C_LANGUAGE_STANDARD = gnu11; + INFOPLIST_FILE = shareextension/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 14.5; + LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @executable_path/../../Frameworks"; + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + MTL_FAST_MATH = YES; + PRODUCT_BUNDLE_IDENTIFIER = com.swmansion.dominik.multitarget.shareextension; + PRODUCT_NAME = "$(TARGET_NAME)"; + SKIP_INSTALL = YES; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + 6ADAD6F026493A420001F56E /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CODE_SIGN_STYLE = Manual; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + DEVELOPMENT_TEAM = "ABCDEFGH"; + GCC_C_LANGUAGE_STANDARD = gnu11; + INFOPLIST_FILE = shareextension/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 14.5; + LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @executable_path/../../Frameworks"; + MTL_FAST_MATH = YES; + PRODUCT_BUNDLE_IDENTIFIER = com.swmansion.dominik.multitarget.shareextension; + PRODUCT_NAME = "$(TARGET_NAME)"; + SKIP_INSTALL = YES; + TARGETED_DEVICE_FAMILY = "1,2"; + PROVISIONING_PROFILE_SPECIFIER = "extension profile"; + CODE_SIGN_IDENTITY = "iPhone Distribution"; + }; + name = Release; + }; + 83CBBA201A601CBA00E9B192 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_SYMBOLS_PRIVATE_EXTERN = NO; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 11.0; + LD_RUNPATH_SEARCH_PATHS = "/usr/lib/swift $(inherited)"; + LIBRARY_SEARCH_PATHS = ( + "\\"$(TOOLCHAIN_DIR)/usr/lib/swift/$(PLATFORM_NAME)\\"", + "\\"$(TOOLCHAIN_DIR)/usr/lib/swift-5.0/$(PLATFORM_NAME)\\"", + "\\"$(inherited)\\"", + ); + MTL_ENABLE_DEBUG_INFO = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = iphoneos; + }; + name = Debug; + }; + 83CBBA211A601CBA00E9B192 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = YES; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 11.0; + LD_RUNPATH_SEARCH_PATHS = "/usr/lib/swift $(inherited)"; + LIBRARY_SEARCH_PATHS = ( + "\\"$(TOOLCHAIN_DIR)/usr/lib/swift/$(PLATFORM_NAME)\\"", + "\\"$(TOOLCHAIN_DIR)/usr/lib/swift-5.0/$(PLATFORM_NAME)\\"", + "\\"$(inherited)\\"", + ); + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = iphoneos; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 13B07F931A680F5B00A75B9A /* Build configuration list for PBXNativeTarget "multitarget" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 13B07F941A680F5B00A75B9A /* Debug */, + 13B07F951A680F5B00A75B9A /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 6ADAD6EE26493A420001F56E /* Build configuration list for PBXNativeTarget "shareextension" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 6ADAD6EF26493A420001F56E /* Debug */, + 6ADAD6F026493A420001F56E /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 83CBB9FA1A601CBA00E9B192 /* Build configuration list for PBXProject "multitarget" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 83CBBA201A601CBA00E9B192 /* Debug */, + 83CBBA211A601CBA00E9B192 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + }; + rootObject = 83CBB9F71A601CBA00E9B192 /* Project object */; +} +" +`; diff --git a/packages/build-tools/src/ios/__tests__/__snapshots__/fastfile.test.ts.snap b/packages/build-tools/src/ios/__tests__/__snapshots__/fastfile.test.ts.snap new file mode 100644 index 0000000000..6657fc6736 --- /dev/null +++ b/packages/build-tools/src/ios/__tests__/__snapshots__/fastfile.test.ts.snap @@ -0,0 +1,88 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`fastfile createFastfileForResigningBuild should create Fastfile with all variables substituted correctly 1`] = ` +"lane :do_resign do + resign( + ipa: "/builds/MyApp.ipa", + signing_identity: "iPhone Distribution: Example Inc (ABC123)", + provisioning_profile: { + "com.example.app" => "/path/to/profiles/main.mobileprovision", + "com.example.app.widget" => "/path/to/profiles/widget.mobileprovision", + }, + keychain_path: "/Users/expo/Library/Keychains/login.keychain" + ) +end +" +`; + +exports[`fastfile createFastfileForResigningBuild should handle empty provisioning profiles 1`] = ` +"lane :do_resign do + resign( + ipa: "/tmp/app.ipa", + signing_identity: "iPhone Distribution", + provisioning_profile: { + }, + keychain_path: "/tmp/keychain" + ) +end +" +`; + +exports[`fastfile createFastfileForResigningBuild should handle multiple provisioning profiles correctly 1`] = ` +"lane :do_resign do + resign( + ipa: "/tmp/app.ipa", + signing_identity: "iPhone Distribution: Company Name", + provisioning_profile: { + "com.example.app" => "/profiles/main.mobileprovision", + "com.example.app.widget" => "/profiles/widget.mobileprovision", + "com.example.app.extension" => "/profiles/extension.mobileprovision", + "com.example.app.intents" => "/profiles/intents.mobileprovision", + }, + keychain_path: "/tmp/keychain" + ) +end +" +`; + +exports[`fastfile createFastfileForResigningBuild should handle paths with special characters and spaces 1`] = ` +"lane :do_resign do + resign( + ipa: "/builds/My App (Release).ipa", + signing_identity: "iPhone Distribution: Example Inc (ABC123XYZ)", + provisioning_profile: { + "com.example.app" => "/path/with spaces/profile (1).mobileprovision", + }, + keychain_path: "/Users/expo/Library/Keychains/login keychain.keychain" + ) +end +" +`; + +exports[`fastfile createFastfileForResigningBuild should handle single provisioning profile 1`] = ` +"lane :do_resign do + resign( + ipa: "/tmp/app.ipa", + signing_identity: "iPhone Distribution", + provisioning_profile: { + "com.example.app" => "/tmp/profile.mobileprovision", + }, + keychain_path: "/tmp/keychain" + ) +end +" +`; + +exports[`fastfile createFastfileForResigningBuild should produce valid Ruby syntax 1`] = ` +"lane :do_resign do + resign( + ipa: "/tmp/app.ipa", + signing_identity: "iPhone Distribution", + provisioning_profile: { + "com.example.app" => "/path/to/profile.mobileprovision", + }, + keychain_path: "/tmp/keychain" + ) +end +" +`; diff --git a/packages/build-tools/src/ios/__tests__/__snapshots__/gymfile.test.ts.snap b/packages/build-tools/src/ios/__tests__/__snapshots__/gymfile.test.ts.snap new file mode 100644 index 0000000000..0067872288 --- /dev/null +++ b/packages/build-tools/src/ios/__tests__/__snapshots__/gymfile.test.ts.snap @@ -0,0 +1,157 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`gymfile createGymfileForArchiveBuild should create Gymfile with all variables substituted correctly 1`] = ` +"suppress_xcode_output(true) +clean(true) + +scheme("MyApp") + +configuration("Release") + + +export_options({ + method: "app-store", + provisioningProfiles: { + "com.example.app" => "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", + "com.example.app.widget" => "ffffffff-0000-1111-2222-333333333333", + } +}) + +export_xcargs "OTHER_CODE_SIGN_FLAGS=\\"--keychain /Users/expo/Library/Keychains/login.keychain\\"" + +disable_xcpretty(true) +buildlog_path("/tmp/logs") + +output_directory("/tmp/output") +" +`; + +exports[`gymfile createGymfileForArchiveBuild should create Gymfile without build configuration when not provided 1`] = ` +"suppress_xcode_output(true) +clean(false) + +scheme("TestScheme") + + +export_options({ + method: "ad-hoc", + provisioningProfiles: { + "com.example.app" => "test-uuid", + } +}) + +export_xcargs "OTHER_CODE_SIGN_FLAGS=\\"--keychain /tmp/keychain\\"" + +disable_xcpretty(true) +buildlog_path("/tmp/logs") + +output_directory("/tmp/output") +" +`; + +exports[`gymfile createGymfileForArchiveBuild should handle multiple provisioning profiles correctly 1`] = ` +"suppress_xcode_output(true) +clean(true) + +scheme("MyApp") + + +export_options({ + method: "enterprise", + provisioningProfiles: { + "com.example.app" => "main-app-uuid", + "com.example.app.widget" => "widget-uuid", + "com.example.app.extension" => "extension-uuid", + } +}) + +export_xcargs "OTHER_CODE_SIGN_FLAGS=\\"--keychain /tmp/keychain\\"" + +disable_xcpretty(true) +buildlog_path("/tmp/logs") + +output_directory("/tmp/output") +" +`; + +exports[`gymfile createGymfileForArchiveBuild should include iCloud container environment when provided in entitlements 1`] = ` +"suppress_xcode_output(true) +clean(true) + +scheme("MyApp") + +configuration("Release") + + +export_options({ + method: "app-store", + provisioningProfiles: { + "com.example.app" => "test-uuid", + }, + iCloudContainerEnvironment: "Production" + +}) + +export_xcargs "OTHER_CODE_SIGN_FLAGS=\\"--keychain /tmp/keychain\\"" + +disable_xcpretty(true) +buildlog_path("/tmp/logs") + +output_directory("/tmp/output") +" +`; + +exports[`gymfile createGymfileForSimulatorBuild should create Gymfile with all simulator variables substituted correctly 1`] = ` +"suppress_xcode_output(true) +clean(true) + +scheme("MyApp") + +configuration("Debug") + + +derived_data_path("/tmp/derived-data") +skip_package_ipa(true) +skip_archive(true) +destination("generic/platform=iOS Simulator") + +disable_xcpretty(true) +buildlog_path("/tmp/logs") +" +`; + +exports[`gymfile createGymfileForSimulatorBuild should create Gymfile without configuration when not provided 1`] = ` +"suppress_xcode_output(true) +clean(false) + +scheme("TestApp") + + +derived_data_path("/tmp/derived") +skip_package_ipa(true) +skip_archive(true) +destination("platform=iOS Simulator,name=iPhone 15") + +disable_xcpretty(true) +buildlog_path("/tmp/logs") +" +`; + +exports[`gymfile createGymfileForSimulatorBuild should handle tvOS simulator destination 1`] = ` +"suppress_xcode_output(true) +clean(true) + +scheme("MyTVApp") + +configuration("Debug") + + +derived_data_path("/tmp/derived") +skip_package_ipa(true) +skip_archive(true) +destination("generic/platform=tvOS Simulator") + +disable_xcpretty(true) +buildlog_path("/tmp/logs") +" +`; diff --git a/packages/build-tools/src/ios/__tests__/configure.test.ts b/packages/build-tools/src/ios/__tests__/configure.test.ts new file mode 100644 index 0000000000..05861973e3 --- /dev/null +++ b/packages/build-tools/src/ios/__tests__/configure.test.ts @@ -0,0 +1,242 @@ +import path from 'path'; + +import { Ios } from '@expo/eas-build-job'; +import { vol } from 'memfs'; + +import { configureXcodeProject } from '../configure'; +import ProvisioningProfile, { DistributionType } from '../credentials/provisioningProfile'; + +jest.mock('fs'); +const originalFs = jest.requireActual('fs'); + +afterEach(() => { + vol.reset(); +}); + +describe(configureXcodeProject, () => { + it('configures credentials for a simple project', async () => { + vol.fromJSON( + { + 'ios/testapp.xcodeproj/project.pbxproj': originalFs.readFileSync( + path.join(__dirname, 'fixtures/simple-project.pbxproj'), + 'utf-8' + ), + 'ios/testapp/AppDelegate.m': 'placeholder', + }, + '/app' + ); + const options = { + credentials: { + keychainPath: 'fake/path', + targetProvisioningProfiles: { + testapp: { + path: 'fake/path.mobileprovision', + target: 'testapp', + bundleIdentifier: 'abc', + teamId: 'ABCDEFGH', + uuid: 'abc', + name: 'profile name', + developerCertificate: Buffer.from('test'), + certificateCommonName: 'Abc 123', + distributionType: DistributionType.APP_STORE, + }, + }, + distributionType: DistributionType.APP_STORE, + teamId: 'ABCDEFGH', + applicationTargetProvisioningProfile: {} as ProvisioningProfile, + }, + buildConfiguration: 'Release', + }; + const ctx = { + getReactNativeProjectDirectory: () => '/app', + logger: { info: jest.fn() }, + job: {}, + }; + await configureXcodeProject(ctx as any, options); + expect( + vol.readFileSync('/app/ios/testapp.xcodeproj/project.pbxproj', 'utf-8') + ).toMatchSnapshot(); + }); + it('configures credentials for multi target project', async () => { + vol.fromJSON( + { + 'ios/testapp.xcodeproj/project.pbxproj': originalFs.readFileSync( + path.join(__dirname, 'fixtures/multitarget-project.pbxproj'), + 'utf-8' + ), + 'ios/testapp/AppDelegate.m': 'placeholder', + }, + '/app' + ); + const options = { + credentials: { + keychainPath: 'fake/path', + targetProvisioningProfiles: { + shareextension: { + path: 'fake/path1.mobileprovision', + target: 'shareextension', + bundleIdentifier: 'abc.extension', + teamId: 'ABCDEFGH', + uuid: 'abc', + name: 'extension profile', + developerCertificate: Buffer.from('test'), + certificateCommonName: 'Abc 123', + distributionType: DistributionType.APP_STORE, + }, + multitarget: { + path: 'fake/path2.mobileprovision', + target: 'multitarget', + bundleIdentifier: 'abc', + teamId: 'ABCDEFGH', + uuid: 'abc', + name: 'multitarget profile', + developerCertificate: Buffer.from('test'), + certificateCommonName: 'Abc 123', + distributionType: DistributionType.APP_STORE, + }, + }, + distributionType: DistributionType.APP_STORE, + teamId: 'ABCDEFGH', + applicationTargetProvisioningProfile: {} as ProvisioningProfile, + }, + buildConfiguration: 'Release', + }; + const ctx = { + getReactNativeProjectDirectory: () => '/app', + logger: { info: jest.fn() }, + job: {}, + }; + await configureXcodeProject(ctx as any, options); + expect( + vol.readFileSync('/app/ios/testapp.xcodeproj/project.pbxproj', 'utf-8') + ).toMatchSnapshot(); + }); + it('configures credentials and versions for a simple project', async () => { + vol.fromJSON( + { + 'ios/testapp/Info.plist': originalFs.readFileSync( + path.join(__dirname, 'fixtures/Info.plist'), + 'utf-8' + ), + 'ios/testapp.xcodeproj/project.pbxproj': originalFs.readFileSync( + path.join(__dirname, 'fixtures/simple-project.pbxproj'), + 'utf-8' + ), + 'ios/testapp/AppDelegate.m': 'placeholder', + }, + '/app' + ); + const options = { + credentials: { + keychainPath: 'fake/path', + targetProvisioningProfiles: { + testapp: { + path: 'fake/path.mobileprovision', + target: 'testapp', + bundleIdentifier: 'abc', + teamId: 'ABCDEFGH', + uuid: 'abc', + name: 'profile name', + developerCertificate: Buffer.from('test'), + certificateCommonName: 'Abc 123', + distributionType: DistributionType.APP_STORE, + }, + }, + distributionType: DistributionType.APP_STORE, + teamId: 'ABCDEFGH', + applicationTargetProvisioningProfile: {} as ProvisioningProfile, + }, + buildConfiguration: 'Release', + }; + const ctx = { + getReactNativeProjectDirectory: () => '/app', + logger: { info: jest.fn() }, + job: { + version: { + appVersion: '1.2.3', + buildNumber: '1.2.4', + }, + }, + }; + await configureXcodeProject(ctx as any, options); + expect( + vol.readFileSync('/app/ios/testapp.xcodeproj/project.pbxproj', 'utf-8') + ).toMatchSnapshot(); + expect(vol.readFileSync('/app/ios/testapp/Info.plist', 'utf-8')).toMatchSnapshot( + 'Info.plist application target' + ); + }); + it('configures credentials and versions for multi target project', async () => { + vol.fromJSON( + { + 'ios/multitarget/Info.plist': originalFs.readFileSync( + path.join(__dirname, 'fixtures/Info.plist'), + 'utf-8' + ), + 'ios/shareextension/Info.plist': originalFs.readFileSync( + path.join(__dirname, 'fixtures/Info.plist'), + 'utf-8' + ), + 'ios/testapp.xcodeproj/project.pbxproj': originalFs.readFileSync( + path.join(__dirname, 'fixtures/multitarget-project.pbxproj'), + 'utf-8' + ), + 'ios/testapp/AppDelegate.m': 'placeholder', + }, + '/app' + ); + const options = { + credentials: { + keychainPath: 'fake/path', + targetProvisioningProfiles: { + shareextension: { + path: 'fake/path1.mobileprovision', + target: 'shareextension', + bundleIdentifier: 'abc.extension', + teamId: 'ABCDEFGH', + uuid: 'abc', + name: 'extension profile', + developerCertificate: Buffer.from('test'), + certificateCommonName: 'Abc 123', + distributionType: DistributionType.APP_STORE, + }, + multitarget: { + path: 'fake/path2.mobileprovision', + target: 'multitarget', + bundleIdentifier: 'abc', + teamId: 'ABCDEFGH', + uuid: 'abc', + name: 'multitarget profile', + developerCertificate: Buffer.from('test'), + certificateCommonName: 'Abc 123', + distributionType: DistributionType.APP_STORE, + }, + }, + distributionType: DistributionType.APP_STORE, + teamId: 'ABCDEFGH', + applicationTargetProvisioningProfile: {} as ProvisioningProfile, + }, + buildConfiguration: 'Release', + }; + const ctx = { + getReactNativeProjectDirectory: () => '/app', + logger: { info: jest.fn() }, + job: { + version: { + appVersion: '1.2.3', + buildNumber: '1.2.4', + }, + }, + }; + await configureXcodeProject(ctx as any, options); + expect( + vol.readFileSync('/app/ios/testapp.xcodeproj/project.pbxproj', 'utf-8') + ).toMatchSnapshot(); + expect(vol.readFileSync('/app/ios/shareextension/Info.plist', 'utf-8')).toMatchSnapshot( + 'Info.plist application target' + ); + expect(vol.readFileSync('/app/ios/multitarget/Info.plist', 'utf-8')).toMatchSnapshot( + 'Info.plist extension' + ); + }); +}); diff --git a/packages/build-tools/src/ios/__tests__/expoUpdates.test.ts b/packages/build-tools/src/ios/__tests__/expoUpdates.test.ts new file mode 100644 index 0000000000..3f1641f49d --- /dev/null +++ b/packages/build-tools/src/ios/__tests__/expoUpdates.test.ts @@ -0,0 +1,169 @@ +import fs from 'fs-extra'; +import plist from '@expo/plist'; +import { vol } from 'memfs'; + +import { + IosMetadataName, + iosSetChannelNativelyAsync, + iosGetNativelyDefinedChannelAsync, + iosGetNativelyDefinedRuntimeVersionAsync, + iosSetRuntimeVersionNativelyAsync, +} from '../../ios/expoUpdates'; + +jest.mock('fs'); + +const expoPlistPath = '/app/ios/testapp/Supporting/Expo.plist'; +const noItemsExpoPlist = ` + + + + + +`; +const channel = 'easupdatechannel'; + +afterEach(() => { + vol.reset(); +}); + +describe(iosSetChannelNativelyAsync, () => { + it('sets the channel', async () => { + vol.fromJSON( + { + 'ios/testapp/Supporting/Expo.plist': noItemsExpoPlist, + 'ios/testapp.xcodeproj/project.pbxproj': 'placeholder', + 'ios/testapp/AppDelegate.m': 'placeholder', + }, + '/app' + ); + + const ctx = { + getReactNativeProjectDirectory: () => '/app', + job: { updates: { channel } }, + logger: { info: () => {} }, + }; + await iosSetChannelNativelyAsync(ctx as any); + + const newExpoPlist = await fs.readFile(expoPlistPath, 'utf8'); + expect( + plist.parse(newExpoPlist)[IosMetadataName.UPDATES_CONFIGURATION_REQUEST_HEADERS_KEY] + ).toEqual({ 'expo-channel-name': channel }); + }); +}); + +describe(iosGetNativelyDefinedChannelAsync, () => { + it('gets the channel', async () => { + const expoPlist = ` + + + + + EXUpdatesRequestHeaders + + expo-channel-name + staging-123 + + + + `; + + vol.fromJSON( + { + 'ios/testapp/Supporting/Expo.plist': expoPlist, + 'ios/testapp.xcodeproj/project.pbxproj': 'placeholder', + 'ios/testapp/AppDelegate.m': 'placeholder', + }, + '/app' + ); + + const ctx = { + getReactNativeProjectDirectory: () => '/app', + logger: { info: () => {} }, + }; + await expect(iosGetNativelyDefinedChannelAsync(ctx as any)).resolves.toBe('staging-123'); + }); +}); + +describe(iosGetNativelyDefinedRuntimeVersionAsync, () => { + it('gets the natively defined runtime version', async () => { + const expoPlist = ` + + + + + EXUpdatesRuntimeVersion + 4.5.6 + + `; + + vol.fromJSON( + { + 'ios/testapp/Supporting/Expo.plist': expoPlist, + 'ios/testapp.xcodeproj/project.pbxproj': 'placeholder', + 'ios/testapp/AppDelegate.m': 'placeholder', + }, + '/app' + ); + + const ctx = { + getReactNativeProjectDirectory: () => '/app', + logger: { info: () => {} }, + }; + + const nativelyDefinedRuntimeVersion = await iosGetNativelyDefinedRuntimeVersionAsync( + ctx as any + ); + expect(nativelyDefinedRuntimeVersion).toBe('4.5.6'); + }); +}); + +describe(iosSetRuntimeVersionNativelyAsync, () => { + it("sets runtime version if it's not specified", async () => { + vol.fromJSON( + { + 'ios/testapp/Supporting/Expo.plist': noItemsExpoPlist, + 'ios/testapp.xcodeproj/project.pbxproj': 'placeholder', + 'ios/testapp/AppDelegate.m': 'placeholder', + }, + '/app' + ); + const ctx = { + getReactNativeProjectDirectory: () => '/app', + logger: { info: () => {} }, + }; + + await iosSetRuntimeVersionNativelyAsync(ctx as any, '1.2.3'); + + const newExpoPlist = await fs.readFile(expoPlistPath, 'utf8'); + expect(plist.parse(newExpoPlist)[IosMetadataName.RUNTIME_VERSION]).toEqual('1.2.3'); + }); + it("updates runtime version if it's already defined", async () => { + const expoPlist = ` + + + + + RELEASE_CHANNEL + examplereleasechannel + + `; + + vol.fromJSON( + { + 'ios/testapp/Supporting/Expo.plist': expoPlist, + 'ios/testapp.xcodeproj/project.pbxproj': 'placeholder', + 'ios/testapp/AppDelegate.m': 'placeholder', + }, + '/app' + ); + const ctx = { + getReactNativeProjectDirectory: () => '/app', + logger: { info: () => {} }, + }; + + await iosSetRuntimeVersionNativelyAsync(ctx as any, '1.2.3'); + + const newExpoPlist = await fs.readFile(expoPlistPath, 'utf8'); + expect(plist.parse(newExpoPlist)[IosMetadataName.RUNTIME_VERSION]).toEqual('1.2.3'); + }); +}); diff --git a/packages/build-tools/src/ios/__tests__/fastfile.test.ts b/packages/build-tools/src/ios/__tests__/fastfile.test.ts new file mode 100644 index 0000000000..8aea0b64a6 --- /dev/null +++ b/packages/build-tools/src/ios/__tests__/fastfile.test.ts @@ -0,0 +1,172 @@ +import { vol } from 'memfs'; + +import { createFastfileForResigningBuild } from '../fastfile'; +import { TargetProvisioningProfiles } from '../credentials/manager'; + +describe('fastfile', () => { + beforeEach(() => { + vol.reset(); + // Set up /tmp directory in the mock filesystem + vol.fromJSON({ + '/tmp/.keep': '', // Create /tmp directory + }); + }); + + afterEach(() => { + vol.reset(); + }); + + describe('createFastfileForResigningBuild', () => { + it('should create Fastfile with all variables substituted correctly', async () => { + const mockTargetProvisioningProfiles: TargetProvisioningProfiles = { + 'com.example.app': { + bundleIdentifier: 'com.example.app', + uuid: 'test-uuid-1', + path: '/path/to/profiles/main.mobileprovision', + } as any, + 'com.example.app.widget': { + bundleIdentifier: 'com.example.app.widget', + uuid: 'test-uuid-2', + path: '/path/to/profiles/widget.mobileprovision', + } as any, + }; + + const outputFile = '/tmp/Fastfile'; + + await createFastfileForResigningBuild({ + outputFile, + ipaPath: '/builds/MyApp.ipa', + signingIdentity: 'iPhone Distribution: Example Inc (ABC123)', + keychainPath: '/Users/expo/Library/Keychains/login.keychain', + targetProvisioningProfiles: mockTargetProvisioningProfiles, + }); + + const generatedContent = vol.readFileSync(outputFile, 'utf-8') as string; + expect(generatedContent).toMatchSnapshot(); + }); + + it('should handle single provisioning profile', async () => { + const mockTargetProvisioningProfiles: TargetProvisioningProfiles = { + 'com.example.app': { + bundleIdentifier: 'com.example.app', + uuid: 'single-uuid', + path: '/tmp/profile.mobileprovision', + } as any, + }; + + const outputFile = '/tmp/Fastfile'; + + await createFastfileForResigningBuild({ + outputFile, + ipaPath: '/tmp/app.ipa', + signingIdentity: 'iPhone Distribution', + keychainPath: '/tmp/keychain', + targetProvisioningProfiles: mockTargetProvisioningProfiles, + }); + + const generatedContent = vol.readFileSync(outputFile, 'utf-8') as string; + expect(generatedContent).toMatchSnapshot(); + }); + + it('should handle multiple provisioning profiles correctly', async () => { + const mockTargetProvisioningProfiles: TargetProvisioningProfiles = { + 'com.example.app': { + bundleIdentifier: 'com.example.app', + uuid: 'uuid-main', + path: '/profiles/main.mobileprovision', + } as any, + 'com.example.app.widget': { + bundleIdentifier: 'com.example.app.widget', + uuid: 'uuid-widget', + path: '/profiles/widget.mobileprovision', + } as any, + 'com.example.app.extension': { + bundleIdentifier: 'com.example.app.extension', + uuid: 'uuid-extension', + path: '/profiles/extension.mobileprovision', + } as any, + 'com.example.app.intents': { + bundleIdentifier: 'com.example.app.intents', + uuid: 'uuid-intents', + path: '/profiles/intents.mobileprovision', + } as any, + }; + + const outputFile = '/tmp/Fastfile'; + + await createFastfileForResigningBuild({ + outputFile, + ipaPath: '/tmp/app.ipa', + signingIdentity: 'iPhone Distribution: Company Name', + keychainPath: '/tmp/keychain', + targetProvisioningProfiles: mockTargetProvisioningProfiles, + }); + + const generatedContent = vol.readFileSync(outputFile, 'utf-8') as string; + expect(generatedContent).toMatchSnapshot(); + }); + + it('should handle empty provisioning profiles', async () => { + const mockTargetProvisioningProfiles: TargetProvisioningProfiles = {}; + + const outputFile = '/tmp/Fastfile'; + + await createFastfileForResigningBuild({ + outputFile, + ipaPath: '/tmp/app.ipa', + signingIdentity: 'iPhone Distribution', + keychainPath: '/tmp/keychain', + targetProvisioningProfiles: mockTargetProvisioningProfiles, + }); + + const generatedContent = vol.readFileSync(outputFile, 'utf-8') as string; + expect(generatedContent).toMatchSnapshot(); + }); + + it('should handle paths with special characters and spaces', async () => { + const mockTargetProvisioningProfiles: TargetProvisioningProfiles = { + 'com.example.app': { + bundleIdentifier: 'com.example.app', + uuid: 'test-uuid', + path: '/path/with spaces/profile (1).mobileprovision', + } as any, + }; + + const outputFile = '/tmp/Fastfile'; + + await createFastfileForResigningBuild({ + outputFile, + ipaPath: '/builds/My App (Release).ipa', + signingIdentity: 'iPhone Distribution: Example Inc (ABC123XYZ)', + keychainPath: '/Users/expo/Library/Keychains/login keychain.keychain', + targetProvisioningProfiles: mockTargetProvisioningProfiles, + }); + + const generatedContent = vol.readFileSync(outputFile, 'utf-8') as string; + expect(generatedContent).toMatchSnapshot(); + }); + + it('should produce valid Ruby syntax', async () => { + const mockTargetProvisioningProfiles: TargetProvisioningProfiles = { + 'com.example.app': { + bundleIdentifier: 'com.example.app', + uuid: 'test-uuid', + path: '/path/to/profile.mobileprovision', + } as any, + }; + + const outputFile = '/tmp/Fastfile'; + + await createFastfileForResigningBuild({ + outputFile, + ipaPath: '/tmp/app.ipa', + signingIdentity: 'iPhone Distribution', + keychainPath: '/tmp/keychain', + targetProvisioningProfiles: mockTargetProvisioningProfiles, + }); + + const generatedContent = vol.readFileSync(outputFile, 'utf-8') as string; + expect(generatedContent).toMatchSnapshot(); + }); + }); +}); diff --git a/packages/build-tools/src/ios/__tests__/fixtures/Info.plist b/packages/build-tools/src/ios/__tests__/fixtures/Info.plist new file mode 100644 index 0000000000..94b0865318 --- /dev/null +++ b/packages/build-tools/src/ios/__tests__/fixtures/Info.plist @@ -0,0 +1,76 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + testapp + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + $(PRODUCT_BUNDLE_PACKAGE_TYPE) + CFBundleShortVersionString + 1.0.0 + CFBundleSignature + ???? + CFBundleURLTypes + + + CFBundleURLSchemes + + org.reactjs.native.example.testapp.turtlev2 + + + + CFBundleVersion + 1 + LSRequiresIPhoneOS + + NSAppTransportSecurity + + NSAllowsArbitraryLoads + + NSExceptionDomains + + localhost + + NSExceptionAllowsInsecureHTTPLoads + + + + + UILaunchStoryboardName + SplashScreen + UIRequiredDeviceCapabilities + + armv7 + + UIRequiresFullScreen + + UIStatusBarStyle + UIStatusBarStyleDefault + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + + UISupportedInterfaceOrientations~ipad + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UIUserInterfaceStyle + Light + UIViewControllerBasedStatusBarAppearance + + + diff --git a/packages/build-tools/src/ios/__tests__/fixtures/multitarget-project.pbxproj b/packages/build-tools/src/ios/__tests__/fixtures/multitarget-project.pbxproj new file mode 100644 index 0000000000..2056ba080a --- /dev/null +++ b/packages/build-tools/src/ios/__tests__/fixtures/multitarget-project.pbxproj @@ -0,0 +1,655 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 46; + objects = { + +/* Begin PBXBuildFile section */ + 13B07FBC1A68108700A75B9A /* AppDelegate.m in Sources */ = {isa = PBXBuildFile; fileRef = 13B07FB01A68108700A75B9A /* AppDelegate.m */; }; + 13B07FBD1A68108700A75B9A /* LaunchScreen.xib in Resources */ = {isa = PBXBuildFile; fileRef = 13B07FB11A68108700A75B9A /* LaunchScreen.xib */; }; + 13B07FBF1A68108700A75B9A /* Images.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 13B07FB51A68108700A75B9A /* Images.xcassets */; }; + 13B07FC11A68108700A75B9A /* main.m in Sources */ = {isa = PBXBuildFile; fileRef = 13B07FB71A68108700A75B9A /* main.m */; }; + 3E461D99554A48A4959DE609 /* SplashScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = AA286B85B6C04FC6940260E9 /* SplashScreen.storyboard */; }; + 6ADAD6E626493A420001F56E /* ShareViewController.m in Sources */ = {isa = PBXBuildFile; fileRef = 6ADAD6E526493A420001F56E /* ShareViewController.m */; }; + 6ADAD6E926493A420001F56E /* MainInterface.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 6ADAD6E726493A420001F56E /* MainInterface.storyboard */; }; + 6ADAD6ED26493A420001F56E /* shareextension.appex in Embed App Extensions */ = {isa = PBXBuildFile; fileRef = 6ADAD6E226493A420001F56E /* shareextension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; + 96905EF65AED1B983A6B3ABC /* libPods-multitarget.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 58EEBF8E8E6FB1BC6CAF49B5 /* libPods-multitarget.a */; }; + BB2F792D24A3F905000567C9 /* Expo.plist in Resources */ = {isa = PBXBuildFile; fileRef = BB2F792C24A3F905000567C9 /* Expo.plist */; }; +/* End PBXBuildFile section */ + +/* Begin PBXContainerItemProxy section */ + 6ADAD6EB26493A420001F56E /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 83CBB9F71A601CBA00E9B192 /* Project object */; + proxyType = 1; + remoteGlobalIDString = 6ADAD6E126493A420001F56E; + remoteInfo = shareextension; + }; +/* End PBXContainerItemProxy section */ + +/* Begin PBXCopyFilesBuildPhase section */ + 6ADAD6F126493A420001F56E /* Embed App Extensions */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 13; + files = ( + 6ADAD6ED26493A420001F56E /* shareextension.appex in Embed App Extensions */, + ); + name = "Embed App Extensions"; + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXCopyFilesBuildPhase section */ + +/* Begin PBXFileReference section */ + 008F07F21AC5B25A0029DE68 /* main.jsbundle */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text; path = main.jsbundle; sourceTree = ""; }; + 13B07F961A680F5B00A75B9A /* multitarget.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = multitarget.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 13B07FAF1A68108700A75B9A /* AppDelegate.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; name = AppDelegate.h; path = multitarget/AppDelegate.h; sourceTree = ""; }; + 13B07FB01A68108700A75B9A /* AppDelegate.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; name = AppDelegate.m; path = multitarget/AppDelegate.m; sourceTree = ""; }; + 13B07FB21A68108700A75B9A /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.xib; name = Base; path = Base.lproj/LaunchScreen.xib; sourceTree = ""; }; + 13B07FB51A68108700A75B9A /* Images.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; name = Images.xcassets; path = multitarget/Images.xcassets; sourceTree = ""; }; + 13B07FB61A68108700A75B9A /* Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = Info.plist; path = multitarget/Info.plist; sourceTree = ""; }; + 13B07FB71A68108700A75B9A /* main.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; name = main.m; path = multitarget/main.m; sourceTree = ""; }; + 58EEBF8E8E6FB1BC6CAF49B5 /* libPods-multitarget.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = "libPods-multitarget.a"; sourceTree = BUILT_PRODUCTS_DIR; }; + 6ADAD6E226493A420001F56E /* shareextension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = shareextension.appex; sourceTree = BUILT_PRODUCTS_DIR; }; + 6ADAD6E426493A420001F56E /* ShareViewController.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = ShareViewController.h; sourceTree = ""; }; + 6ADAD6E526493A420001F56E /* ShareViewController.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = ShareViewController.m; sourceTree = ""; }; + 6ADAD6E826493A420001F56E /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/MainInterface.storyboard; sourceTree = ""; }; + 6ADAD6EA26493A420001F56E /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + 6C2E3173556A471DD304B334 /* Pods-multitarget.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-multitarget.debug.xcconfig"; path = "Target Support Files/Pods-multitarget/Pods-multitarget.debug.xcconfig"; sourceTree = ""; }; + 7A4D352CD337FB3A3BF06240 /* Pods-multitarget.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-multitarget.release.xcconfig"; path = "Target Support Files/Pods-multitarget/Pods-multitarget.release.xcconfig"; sourceTree = ""; }; + AA286B85B6C04FC6940260E9 /* SplashScreen.storyboard */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.storyboard; name = SplashScreen.storyboard; path = multitarget/SplashScreen.storyboard; sourceTree = ""; }; + BB2F792C24A3F905000567C9 /* Expo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = Expo.plist; sourceTree = ""; }; + ED297162215061F000B7C4FE /* JavaScriptCore.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = JavaScriptCore.framework; path = System/Library/Frameworks/JavaScriptCore.framework; sourceTree = SDKROOT; }; + ED2971642150620600B7C4FE /* JavaScriptCore.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = JavaScriptCore.framework; path = Platforms/AppleTVOS.platform/Developer/SDKs/AppleTVOS12.0.sdk/System/Library/Frameworks/JavaScriptCore.framework; sourceTree = DEVELOPER_DIR; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + 13B07F8C1A680F5B00A75B9A /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 96905EF65AED1B983A6B3ABC /* libPods-multitarget.a in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 6ADAD6DF26493A420001F56E /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 13B07FAE1A68108700A75B9A /* multitarget */ = { + isa = PBXGroup; + children = ( + BB2F792B24A3F905000567C9 /* Supporting */, + 008F07F21AC5B25A0029DE68 /* main.jsbundle */, + 13B07FAF1A68108700A75B9A /* AppDelegate.h */, + 13B07FB01A68108700A75B9A /* AppDelegate.m */, + 13B07FB51A68108700A75B9A /* Images.xcassets */, + 13B07FB61A68108700A75B9A /* Info.plist */, + 13B07FB11A68108700A75B9A /* LaunchScreen.xib */, + 13B07FB71A68108700A75B9A /* main.m */, + AA286B85B6C04FC6940260E9 /* SplashScreen.storyboard */, + ); + name = multitarget; + sourceTree = ""; + }; + 2D16E6871FA4F8E400B85C8A /* Frameworks */ = { + isa = PBXGroup; + children = ( + ED297162215061F000B7C4FE /* JavaScriptCore.framework */, + ED2971642150620600B7C4FE /* JavaScriptCore.framework */, + 58EEBF8E8E6FB1BC6CAF49B5 /* libPods-multitarget.a */, + ); + name = Frameworks; + sourceTree = ""; + }; + 6ADAD6E326493A420001F56E /* shareextension */ = { + isa = PBXGroup; + children = ( + 6ADAD6E426493A420001F56E /* ShareViewController.h */, + 6ADAD6E526493A420001F56E /* ShareViewController.m */, + 6ADAD6E726493A420001F56E /* MainInterface.storyboard */, + 6ADAD6EA26493A420001F56E /* Info.plist */, + ); + path = shareextension; + sourceTree = ""; + }; + 832341AE1AAA6A7D00B99B32 /* Libraries */ = { + isa = PBXGroup; + children = ( + ); + name = Libraries; + sourceTree = ""; + }; + 83CBB9F61A601CBA00E9B192 = { + isa = PBXGroup; + children = ( + 13B07FAE1A68108700A75B9A /* multitarget */, + 832341AE1AAA6A7D00B99B32 /* Libraries */, + 6ADAD6E326493A420001F56E /* shareextension */, + 83CBBA001A601CBA00E9B192 /* Products */, + 2D16E6871FA4F8E400B85C8A /* Frameworks */, + D65327D7A22EEC0BE12398D9 /* Pods */, + ); + indentWidth = 2; + sourceTree = ""; + tabWidth = 2; + usesTabs = 0; + }; + 83CBBA001A601CBA00E9B192 /* Products */ = { + isa = PBXGroup; + children = ( + 13B07F961A680F5B00A75B9A /* multitarget.app */, + 6ADAD6E226493A420001F56E /* shareextension.appex */, + ); + name = Products; + sourceTree = ""; + }; + BB2F792B24A3F905000567C9 /* Supporting */ = { + isa = PBXGroup; + children = ( + BB2F792C24A3F905000567C9 /* Expo.plist */, + ); + name = Supporting; + path = multitarget/Supporting; + sourceTree = ""; + }; + D65327D7A22EEC0BE12398D9 /* Pods */ = { + isa = PBXGroup; + children = ( + 6C2E3173556A471DD304B334 /* Pods-multitarget.debug.xcconfig */, + 7A4D352CD337FB3A3BF06240 /* Pods-multitarget.release.xcconfig */, + ); + path = Pods; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 13B07F861A680F5B00A75B9A /* multitarget */ = { + isa = PBXNativeTarget; + buildConfigurationList = 13B07F931A680F5B00A75B9A /* Build configuration list for PBXNativeTarget "multitarget" */; + buildPhases = ( + 08A4A3CD28434E44B6B9DE2E /* [CP] Check Pods Manifest.lock */, + FD10A7F022414F080027D42C /* Start Packager */, + 13B07F871A680F5B00A75B9A /* Sources */, + 13B07F8C1A680F5B00A75B9A /* Frameworks */, + 13B07F8E1A680F5B00A75B9A /* Resources */, + 00DD1BFF1BD5951E006B06BC /* Bundle React Native code and images */, + AF7BF8C823CE838DA4182532 /* [CP] Copy Pods Resources */, + 6ADAD6F126493A420001F56E /* Embed App Extensions */, + ); + buildRules = ( + ); + dependencies = ( + 6ADAD6EC26493A420001F56E /* PBXTargetDependency */, + ); + name = multitarget; + productName = multitarget; + productReference = 13B07F961A680F5B00A75B9A /* multitarget.app */; + productType = "com.apple.product-type.application"; + }; + 6ADAD6E126493A420001F56E /* shareextension */ = { + isa = PBXNativeTarget; + buildConfigurationList = 6ADAD6EE26493A420001F56E /* Build configuration list for PBXNativeTarget "shareextension" */; + buildPhases = ( + 6ADAD6DE26493A420001F56E /* Sources */, + 6ADAD6DF26493A420001F56E /* Frameworks */, + 6ADAD6E026493A420001F56E /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = shareextension; + productName = shareextension; + productReference = 6ADAD6E226493A420001F56E /* shareextension.appex */; + productType = "com.apple.product-type.app-extension"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 83CBB9F71A601CBA00E9B192 /* Project object */ = { + isa = PBXProject; + attributes = { + LastUpgradeCheck = 1130; + TargetAttributes = { + 13B07F861A680F5B00A75B9A = { + DevelopmentTeam = QL76XYH73P; + LastSwiftMigration = 1120; + }; + 6ADAD6E126493A420001F56E = { + CreatedOnToolsVersion = 12.5; + DevelopmentTeam = QL76XYH73P; + ProvisioningStyle = Automatic; + }; + }; + }; + buildConfigurationList = 83CBB9FA1A601CBA00E9B192 /* Build configuration list for PBXProject "multitarget" */; + compatibilityVersion = "Xcode 3.2"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = 83CBB9F61A601CBA00E9B192; + productRefGroup = 83CBBA001A601CBA00E9B192 /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 13B07F861A680F5B00A75B9A /* multitarget */, + 6ADAD6E126493A420001F56E /* shareextension */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 13B07F8E1A680F5B00A75B9A /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + BB2F792D24A3F905000567C9 /* Expo.plist in Resources */, + 13B07FBF1A68108700A75B9A /* Images.xcassets in Resources */, + 13B07FBD1A68108700A75B9A /* LaunchScreen.xib in Resources */, + 3E461D99554A48A4959DE609 /* SplashScreen.storyboard in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 6ADAD6E026493A420001F56E /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 6ADAD6E926493A420001F56E /* MainInterface.storyboard in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXShellScriptBuildPhase section */ + 00DD1BFF1BD5951E006B06BC /* Bundle React Native code and images */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + ); + name = "Bundle React Native code and images"; + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "if [[ -f /Users/dsokal/.volta/bin/node ]]; then\n export NODE_BINARY=/Users/dsokal/.volta/bin/node\nelse\n export NODE_BINARY=node\nfi\n../node_modules/react-native/scripts/react-native-xcode.sh\n../node_modules/expo-constants/scripts/get-app-config-ios.sh\n../node_modules/expo-updates/scripts/create-manifest-ios.sh\n"; + }; + 08A4A3CD28434E44B6B9DE2E /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-multitarget-checkManifestLockResult.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; + }; + AF7BF8C823CE838DA4182532 /* [CP] Copy Pods Resources */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-multitarget/Pods-multitarget-resources.sh", + "${PODS_CONFIGURATION_BUILD_DIR}/React-Core/AccessibilityResources.bundle", + ); + name = "[CP] Copy Pods Resources"; + outputPaths = ( + "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/AccessibilityResources.bundle", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-multitarget/Pods-multitarget-resources.sh\"\n"; + showEnvVarsInLog = 0; + }; + FD10A7F022414F080027D42C /* Start Packager */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + ); + name = "Start Packager"; + outputFileListPaths = ( + ); + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "export RCT_METRO_PORT=\"${RCT_METRO_PORT:=8081}\"\necho \"export RCT_METRO_PORT=${RCT_METRO_PORT}\" > \"${SRCROOT}/../node_modules/react-native/scripts/.packager.env\"\nif [ -z \"${RCT_NO_LAUNCH_PACKAGER+xxx}\" ] ; then\n if nc -w 5 -z localhost ${RCT_METRO_PORT} ; then\n if ! curl -s \"http://localhost:${RCT_METRO_PORT}/status\" | grep -q \"packager-status:running\" ; then\n echo \"Port ${RCT_METRO_PORT} already in use, packager is either not running or not running correctly\"\n exit 2\n fi\n else\n open \"$SRCROOT/../node_modules/react-native/scripts/launchPackager.command\" || echo \"Can't start packager automatically\"\n fi\nfi\n"; + showEnvVarsInLog = 0; + }; +/* End PBXShellScriptBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 13B07F871A680F5B00A75B9A /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 13B07FBC1A68108700A75B9A /* AppDelegate.m in Sources */, + 13B07FC11A68108700A75B9A /* main.m in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 6ADAD6DE26493A420001F56E /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 6ADAD6E626493A420001F56E /* ShareViewController.m in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXTargetDependency section */ + 6ADAD6EC26493A420001F56E /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 6ADAD6E126493A420001F56E /* shareextension */; + targetProxy = 6ADAD6EB26493A420001F56E /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + +/* Begin PBXVariantGroup section */ + 13B07FB11A68108700A75B9A /* LaunchScreen.xib */ = { + isa = PBXVariantGroup; + children = ( + 13B07FB21A68108700A75B9A /* Base */, + ); + name = LaunchScreen.xib; + path = multitarget; + sourceTree = ""; + }; + 6ADAD6E726493A420001F56E /* MainInterface.storyboard */ = { + isa = PBXVariantGroup; + children = ( + 6ADAD6E826493A420001F56E /* Base */, + ); + name = MainInterface.storyboard; + sourceTree = ""; + }; +/* End PBXVariantGroup section */ + +/* Begin XCBuildConfiguration section */ + 13B07F941A680F5B00A75B9A /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 6C2E3173556A471DD304B334 /* Pods-multitarget.debug.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = QL76XYH73P; + ENABLE_BITCODE = NO; + GCC_PREPROCESSOR_DEFINITIONS = ( + "$(inherited)", + "FB_SONARKIT_ENABLED=1", + ); + INFOPLIST_FILE = multitarget/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 11.0; + LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; + OTHER_LDFLAGS = ( + "$(inherited)", + "-ObjC", + "-lc++", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.swmansion.dominik.multitarget.debug; + PRODUCT_NAME = multitarget; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = Debug; + }; + 13B07F951A680F5B00A75B9A /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7A4D352CD337FB3A3BF06240 /* Pods-multitarget.release.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_IDENTITY = "Apple Development"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = ""; + INFOPLIST_FILE = multitarget/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 11.0; + LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; + OTHER_LDFLAGS = ( + "$(inherited)", + "-ObjC", + "-lc++", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.swmansion.dominik.multitarget; + PRODUCT_NAME = multitarget; + PROVISIONING_PROFILE_SPECIFIER = ""; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = Release; + }; + 6ADAD6EF26493A420001F56E /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CODE_SIGN_STYLE = Automatic; + DEBUG_INFORMATION_FORMAT = dwarf; + DEVELOPMENT_TEAM = QL76XYH73P; + GCC_C_LANGUAGE_STANDARD = gnu11; + INFOPLIST_FILE = shareextension/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 14.5; + LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @executable_path/../../Frameworks"; + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + MTL_FAST_MATH = YES; + PRODUCT_BUNDLE_IDENTIFIER = com.swmansion.dominik.multitarget.shareextension; + PRODUCT_NAME = "$(TARGET_NAME)"; + SKIP_INSTALL = YES; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + 6ADAD6F026493A420001F56E /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CODE_SIGN_STYLE = Automatic; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + DEVELOPMENT_TEAM = QL76XYH73P; + GCC_C_LANGUAGE_STANDARD = gnu11; + INFOPLIST_FILE = shareextension/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 14.5; + LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @executable_path/../../Frameworks"; + MTL_FAST_MATH = YES; + PRODUCT_BUNDLE_IDENTIFIER = com.swmansion.dominik.multitarget.shareextension; + PRODUCT_NAME = "$(TARGET_NAME)"; + SKIP_INSTALL = YES; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Release; + }; + 83CBBA201A601CBA00E9B192 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_SYMBOLS_PRIVATE_EXTERN = NO; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 11.0; + LD_RUNPATH_SEARCH_PATHS = "/usr/lib/swift $(inherited)"; + LIBRARY_SEARCH_PATHS = ( + "\"$(TOOLCHAIN_DIR)/usr/lib/swift/$(PLATFORM_NAME)\"", + "\"$(TOOLCHAIN_DIR)/usr/lib/swift-5.0/$(PLATFORM_NAME)\"", + "\"$(inherited)\"", + ); + MTL_ENABLE_DEBUG_INFO = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = iphoneos; + }; + name = Debug; + }; + 83CBBA211A601CBA00E9B192 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = YES; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 11.0; + LD_RUNPATH_SEARCH_PATHS = "/usr/lib/swift $(inherited)"; + LIBRARY_SEARCH_PATHS = ( + "\"$(TOOLCHAIN_DIR)/usr/lib/swift/$(PLATFORM_NAME)\"", + "\"$(TOOLCHAIN_DIR)/usr/lib/swift-5.0/$(PLATFORM_NAME)\"", + "\"$(inherited)\"", + ); + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = iphoneos; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 13B07F931A680F5B00A75B9A /* Build configuration list for PBXNativeTarget "multitarget" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 13B07F941A680F5B00A75B9A /* Debug */, + 13B07F951A680F5B00A75B9A /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 6ADAD6EE26493A420001F56E /* Build configuration list for PBXNativeTarget "shareextension" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 6ADAD6EF26493A420001F56E /* Debug */, + 6ADAD6F026493A420001F56E /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 83CBB9FA1A601CBA00E9B192 /* Build configuration list for PBXProject "multitarget" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 83CBBA201A601CBA00E9B192 /* Debug */, + 83CBBA211A601CBA00E9B192 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + }; + rootObject = 83CBB9F71A601CBA00E9B192 /* Project object */; +} diff --git a/packages/build-tools/src/ios/__tests__/fixtures/simple-project.pbxproj b/packages/build-tools/src/ios/__tests__/fixtures/simple-project.pbxproj new file mode 100644 index 0000000000..2e6616e73b --- /dev/null +++ b/packages/build-tools/src/ios/__tests__/fixtures/simple-project.pbxproj @@ -0,0 +1,492 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 46; + objects = { + +/* Begin PBXBuildFile section */ + 13B07FBC1A68108700A75B9A /* AppDelegate.m in Sources */ = {isa = PBXBuildFile; fileRef = 13B07FB01A68108700A75B9A /* AppDelegate.m */; }; + 13B07FBF1A68108700A75B9A /* Images.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 13B07FB51A68108700A75B9A /* Images.xcassets */; }; + 13B07FC11A68108700A75B9A /* main.m in Sources */ = {isa = PBXBuildFile; fileRef = 13B07FB71A68108700A75B9A /* main.m */; }; + 3E461D99554A48A4959DE609 /* SplashScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = AA286B85B6C04FC6940260E9 /* SplashScreen.storyboard */; }; + 96905EF65AED1B983A6B3ABC /* libPods-testapp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 58EEBF8E8E6FB1BC6CAF49B5 /* libPods-testapp.a */; }; + B18059E884C0ABDD17F3DC3D /* ExpoModulesProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = FAC715A2D49A985799AEE119 /* ExpoModulesProvider.swift */; }; + BB2F792D24A3F905000567C9 /* Expo.plist in Resources */ = {isa = PBXBuildFile; fileRef = BB2F792C24A3F905000567C9 /* Expo.plist */; }; + E112987CA504453081AC7CD8 /* noop-file.swift in Sources */ = {isa = PBXBuildFile; fileRef = C911AA20BDD841449D1CE041 /* noop-file.swift */; }; +/* End PBXBuildFile section */ + +/* Begin PBXFileReference section */ + 008F07F21AC5B25A0029DE68 /* main.jsbundle */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text; path = main.jsbundle; sourceTree = ""; }; + 13B07F961A680F5B00A75B9A /* testapp.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = testapp.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 13B07FAF1A68108700A75B9A /* AppDelegate.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; name = AppDelegate.h; path = testapp/AppDelegate.h; sourceTree = ""; }; + 13B07FB01A68108700A75B9A /* AppDelegate.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; name = AppDelegate.m; path = testapp/AppDelegate.m; sourceTree = ""; }; + 13B07FB51A68108700A75B9A /* Images.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; name = Images.xcassets; path = testapp/Images.xcassets; sourceTree = ""; }; + 13B07FB61A68108700A75B9A /* Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = Info.plist; path = testapp/Info.plist; sourceTree = ""; }; + 13B07FB71A68108700A75B9A /* main.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; name = main.m; path = testapp/main.m; sourceTree = ""; }; + 58EEBF8E8E6FB1BC6CAF49B5 /* libPods-testapp.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = "libPods-testapp.a"; sourceTree = BUILT_PRODUCTS_DIR; }; + 6C2E3173556A471DD304B334 /* Pods-testapp.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-testapp.debug.xcconfig"; path = "Target Support Files/Pods-testapp/Pods-testapp.debug.xcconfig"; sourceTree = ""; }; + 7A4D352CD337FB3A3BF06240 /* Pods-testapp.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-testapp.release.xcconfig"; path = "Target Support Files/Pods-testapp/Pods-testapp.release.xcconfig"; sourceTree = ""; }; + AA286B85B6C04FC6940260E9 /* SplashScreen.storyboard */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.storyboard; name = SplashScreen.storyboard; path = testapp/SplashScreen.storyboard; sourceTree = ""; }; + BB2F792C24A3F905000567C9 /* Expo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = Expo.plist; sourceTree = ""; }; + ED297162215061F000B7C4FE /* JavaScriptCore.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = JavaScriptCore.framework; path = System/Library/Frameworks/JavaScriptCore.framework; sourceTree = SDKROOT; }; + FAC715A2D49A985799AEE119 /* ExpoModulesProvider.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = ExpoModulesProvider.swift; path = "Pods/Target Support Files/Pods-testapp/ExpoModulesProvider.swift"; sourceTree = ""; }; + C911AA20BDD841449D1CE041 /* noop-file.swift */ = {isa = PBXFileReference; name = "noop-file.swift"; path = "testapp/noop-file.swift"; sourceTree = ""; fileEncoding = 4; lastKnownFileType = sourcecode.swift; explicitFileType = undefined; includeInIndex = 0; }; + 0962172184234A4793928022 /* testapp-Bridging-Header.h */ = {isa = PBXFileReference; name = "testapp-Bridging-Header.h"; path = "testapp/testapp-Bridging-Header.h"; sourceTree = ""; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; explicitFileType = undefined; includeInIndex = 0; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + 13B07F8C1A680F5B00A75B9A /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 96905EF65AED1B983A6B3ABC /* libPods-testapp.a in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 13B07FAE1A68108700A75B9A /* testapp */ = { + isa = PBXGroup; + children = ( + BB2F792B24A3F905000567C9 /* Supporting */, + 008F07F21AC5B25A0029DE68 /* main.jsbundle */, + 13B07FAF1A68108700A75B9A /* AppDelegate.h */, + 13B07FB01A68108700A75B9A /* AppDelegate.m */, + 13B07FB51A68108700A75B9A /* Images.xcassets */, + 13B07FB61A68108700A75B9A /* Info.plist */, + 13B07FB71A68108700A75B9A /* main.m */, + AA286B85B6C04FC6940260E9 /* SplashScreen.storyboard */, + C911AA20BDD841449D1CE041 /* noop-file.swift */, + 0962172184234A4793928022 /* testapp-Bridging-Header.h */, + ); + name = testapp; + sourceTree = ""; + }; + 2D16E6871FA4F8E400B85C8A /* Frameworks */ = { + isa = PBXGroup; + children = ( + ED297162215061F000B7C4FE /* JavaScriptCore.framework */, + 58EEBF8E8E6FB1BC6CAF49B5 /* libPods-testapp.a */, + ); + name = Frameworks; + sourceTree = ""; + }; + 832341AE1AAA6A7D00B99B32 /* Libraries */ = { + isa = PBXGroup; + children = ( + ); + name = Libraries; + sourceTree = ""; + }; + 83CBB9F61A601CBA00E9B192 = { + isa = PBXGroup; + children = ( + 13B07FAE1A68108700A75B9A /* testapp */, + 832341AE1AAA6A7D00B99B32 /* Libraries */, + 83CBBA001A601CBA00E9B192 /* Products */, + 2D16E6871FA4F8E400B85C8A /* Frameworks */, + D65327D7A22EEC0BE12398D9 /* Pods */, + D7E4C46ADA2E9064B798F356 /* ExpoModulesProviders */, + ); + indentWidth = 2; + sourceTree = ""; + tabWidth = 2; + usesTabs = 0; + }; + 83CBBA001A601CBA00E9B192 /* Products */ = { + isa = PBXGroup; + children = ( + 13B07F961A680F5B00A75B9A /* testapp.app */, + ); + name = Products; + sourceTree = ""; + }; + 92DBD88DE9BF7D494EA9DA96 /* testapp */ = { + isa = PBXGroup; + children = ( + FAC715A2D49A985799AEE119 /* ExpoModulesProvider.swift */, + ); + name = testapp; + sourceTree = ""; + }; + BB2F792B24A3F905000567C9 /* Supporting */ = { + isa = PBXGroup; + children = ( + BB2F792C24A3F905000567C9 /* Expo.plist */, + ); + name = Supporting; + path = testapp/Supporting; + sourceTree = ""; + }; + D65327D7A22EEC0BE12398D9 /* Pods */ = { + isa = PBXGroup; + children = ( + 6C2E3173556A471DD304B334 /* Pods-testapp.debug.xcconfig */, + 7A4D352CD337FB3A3BF06240 /* Pods-testapp.release.xcconfig */, + ); + path = Pods; + sourceTree = ""; + }; + D7E4C46ADA2E9064B798F356 /* ExpoModulesProviders */ = { + isa = PBXGroup; + children = ( + 92DBD88DE9BF7D494EA9DA96 /* testapp */, + ); + name = ExpoModulesProviders; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 13B07F861A680F5B00A75B9A /* testapp */ = { + isa = PBXNativeTarget; + buildConfigurationList = 13B07F931A680F5B00A75B9A /* Build configuration list for PBXNativeTarget "testapp" */; + buildPhases = ( + 08A4A3CD28434E44B6B9DE2E /* [CP] Check Pods Manifest.lock */, + FD10A7F022414F080027D42C /* Start Packager */, + 13B07F871A680F5B00A75B9A /* Sources */, + 13B07F8C1A680F5B00A75B9A /* Frameworks */, + 13B07F8E1A680F5B00A75B9A /* Resources */, + 00DD1BFF1BD5951E006B06BC /* Bundle React Native code and images */, + 800E24972A6A228C8D4807E9 /* [CP] Copy Pods Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = testapp; + productName = testapp; + productReference = 13B07F961A680F5B00A75B9A /* testapp.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 83CBB9F71A601CBA00E9B192 /* Project object */ = { + isa = PBXProject; + attributes = { + LastUpgradeCheck = 1130; + TargetAttributes = { + 13B07F861A680F5B00A75B9A = { + LastSwiftMigration = 1250; + }; + }; + }; + buildConfigurationList = 83CBB9FA1A601CBA00E9B192 /* Build configuration list for PBXProject "testapp" */; + compatibilityVersion = "Xcode 3.2"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = 83CBB9F61A601CBA00E9B192; + productRefGroup = 83CBBA001A601CBA00E9B192 /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 13B07F861A680F5B00A75B9A /* testapp */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 13B07F8E1A680F5B00A75B9A /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + BB2F792D24A3F905000567C9 /* Expo.plist in Resources */, + 13B07FBF1A68108700A75B9A /* Images.xcassets in Resources */, + 3E461D99554A48A4959DE609 /* SplashScreen.storyboard in Resources */, + C9A08D1C39C94E5D824DFA3F /* testapp-Bridging-Header.h in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXShellScriptBuildPhase section */ + 00DD1BFF1BD5951E006B06BC /* Bundle React Native code and images */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + ); + name = "Bundle React Native code and images"; + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "export NODE_BINARY=node\n\n# The project root by default is one level up from the ios directory\nexport PROJECT_ROOT=\"$PROJECT_DIR\"/..\n\n`node --print \"require('path').dirname(require.resolve('react-native/package.json')) + '/scripts/react-native-xcode.sh'\"`\n"; + }; + 08A4A3CD28434E44B6B9DE2E /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-testapp-checkManifestLockResult.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; + }; + 800E24972A6A228C8D4807E9 /* [CP] Copy Pods Resources */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-testapp/Pods-testapp-resources.sh", + "${PODS_CONFIGURATION_BUILD_DIR}/EXConstants/EXConstants.bundle", + "${PODS_CONFIGURATION_BUILD_DIR}/EXUpdates/EXUpdates.bundle", + "${PODS_CONFIGURATION_BUILD_DIR}/React-Core/AccessibilityResources.bundle", + ); + name = "[CP] Copy Pods Resources"; + outputPaths = ( + "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/EXConstants.bundle", + "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/EXUpdates.bundle", + "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/AccessibilityResources.bundle", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-testapp/Pods-testapp-resources.sh\"\n"; + showEnvVarsInLog = 0; + }; + FD10A7F022414F080027D42C /* Start Packager */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + ); + name = "Start Packager"; + outputFileListPaths = ( + ); + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "export RCT_METRO_PORT=\"${RCT_METRO_PORT:=8081}\"\necho \"export RCT_METRO_PORT=${RCT_METRO_PORT}\" > `node --print \"require('path').dirname(require.resolve('react-native/package.json')) + '/scripts/.packager.env'\"`\nif [ -z \"${RCT_NO_LAUNCH_PACKAGER+xxx}\" ] ; then\n if nc -w 5 -z localhost ${RCT_METRO_PORT} ; then\n if ! curl -s \"http://localhost:${RCT_METRO_PORT}/status\" | grep -q \"packager-status:running\" ; then\n echo \"Port ${RCT_METRO_PORT} already in use, packager is either not running or not running correctly\"\n exit 2\n fi\n else\n open `node --print \"require('path').dirname(require.resolve('react-native/package.json')) + '/scripts/launchPackager.command'\"` || echo \"Can't start packager automatically\"\n fi\nfi\n"; + showEnvVarsInLog = 0; + }; +/* End PBXShellScriptBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 13B07F871A680F5B00A75B9A /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 13B07FBC1A68108700A75B9A /* AppDelegate.m in Sources */, + 13B07FC11A68108700A75B9A /* main.m in Sources */, + B18059E884C0ABDD17F3DC3D /* ExpoModulesProvider.swift in Sources */, + E112987CA504453081AC7CD8 /* noop-file.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin XCBuildConfiguration section */ + 13B07F941A680F5B00A75B9A /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 6C2E3173556A471DD304B334 /* Pods-testapp.debug.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CURRENT_PROJECT_VERSION = 1; + ENABLE_BITCODE = NO; + GCC_PREPROCESSOR_DEFINITIONS = ( + "$(inherited)", + "FB_SONARKIT_ENABLED=1", + ); + INFOPLIST_FILE = testapp/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 12.0; + LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; + OTHER_LDFLAGS = ( + "$(inherited)", + "-ObjC", + "-lc++", + ); + PRODUCT_BUNDLE_IDENTIFIER = "org.reactjs.native.example.testapp.turtlev2"; + PRODUCT_NAME = turtlev2; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + TARGETED_DEVICE_FAMILY = "1,2"; + SWIFT_OBJC_BRIDGING_HEADER = testapp/testapp-Bridging-Header.h; + CODE_SIGN_ENTITLEMENTS = testapp/turtlev2.entitlements; + }; + name = Debug; + }; + 13B07F951A680F5B00A75B9A /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7A4D352CD337FB3A3BF06240 /* Pods-testapp.release.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CURRENT_PROJECT_VERSION = 1; + INFOPLIST_FILE = testapp/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 12.0; + LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; + OTHER_LDFLAGS = ( + "$(inherited)", + "-ObjC", + "-lc++", + ); + PRODUCT_BUNDLE_IDENTIFIER = "org.reactjs.native.example.testapp.turtlev2"; + PRODUCT_NAME = turtlev2; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + TARGETED_DEVICE_FAMILY = "1,2"; + SWIFT_OBJC_BRIDGING_HEADER = testapp/testapp-Bridging-Header.h; + CODE_SIGN_ENTITLEMENTS = testapp/turtlev2.entitlements; + }; + name = Release; + }; + 83CBBA201A601CBA00E9B192 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_SYMBOLS_PRIVATE_EXTERN = NO; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 12.0; + LD_RUNPATH_SEARCH_PATHS = "/usr/lib/swift $(inherited)"; + LIBRARY_SEARCH_PATHS = "\"$(inherited)\""; + MTL_ENABLE_DEBUG_INFO = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = iphoneos; + CODE_SIGN_ENTITLEMENTS = testapp/turtlev2.entitlements; + }; + name = Debug; + }; + 83CBBA211A601CBA00E9B192 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = YES; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 12.0; + LD_RUNPATH_SEARCH_PATHS = "/usr/lib/swift $(inherited)"; + LIBRARY_SEARCH_PATHS = "\"$(inherited)\""; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = iphoneos; + VALIDATE_PRODUCT = YES; + CODE_SIGN_ENTITLEMENTS = testapp/turtlev2.entitlements; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 13B07F931A680F5B00A75B9A /* Build configuration list for PBXNativeTarget "testapp" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 13B07F941A680F5B00A75B9A /* Debug */, + 13B07F951A680F5B00A75B9A /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 83CBB9FA1A601CBA00E9B192 /* Build configuration list for PBXProject "testapp" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 83CBBA201A601CBA00E9B192 /* Debug */, + 83CBBA211A601CBA00E9B192 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + }; + rootObject = 83CBB9F71A601CBA00E9B192 /* Project object */; +} diff --git a/packages/build-tools/src/ios/__tests__/gymfile.test.ts b/packages/build-tools/src/ios/__tests__/gymfile.test.ts new file mode 100644 index 0000000000..209b4faaf5 --- /dev/null +++ b/packages/build-tools/src/ios/__tests__/gymfile.test.ts @@ -0,0 +1,255 @@ +import { vol } from 'memfs'; + +import { createGymfileForArchiveBuild, createGymfileForSimulatorBuild } from '../gymfile'; +import { Credentials } from '../credentials/manager'; +import { DistributionType } from '../credentials/provisioningProfile'; + +describe('gymfile', () => { + beforeEach(() => { + vol.reset(); + // Set up /tmp directory in the mock filesystem + vol.fromJSON({ + '/tmp/.keep': '', // Create /tmp directory + }); + }); + + afterEach(() => { + vol.reset(); + }); + + describe('createGymfileForArchiveBuild', () => { + it('should create Gymfile with all variables substituted correctly', async () => { + const mockCredentials: Credentials = { + keychainPath: '/Users/expo/Library/Keychains/login.keychain', + distributionType: DistributionType.APP_STORE, + teamId: 'TEAM123', + applicationTargetProvisioningProfile: {} as any, + targetProvisioningProfiles: { + 'com.example.app': { + bundleIdentifier: 'com.example.app', + uuid: 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee', + path: '/path/to/profile1.mobileprovision', + target: 'com.example.app', + teamId: 'TEAM123', + name: 'Main App Profile', + developerCertificate: Buffer.from('cert'), + certificateCommonName: 'iPhone Distribution', + distributionType: DistributionType.APP_STORE, + }, + 'com.example.app.widget': { + bundleIdentifier: 'com.example.app.widget', + uuid: 'ffffffff-0000-1111-2222-333333333333', + path: '/path/to/profile2.mobileprovision', + target: 'com.example.app.widget', + teamId: 'TEAM123', + name: 'Widget Profile', + developerCertificate: Buffer.from('cert'), + certificateCommonName: 'iPhone Distribution', + distributionType: DistributionType.APP_STORE, + }, + }, + }; + + const outputFile = '/tmp/Gymfile'; + + await createGymfileForArchiveBuild({ + outputFile, + credentials: mockCredentials, + scheme: 'MyApp', + buildConfiguration: 'Release', + outputDirectory: '/tmp/output', + clean: true, + logsDirectory: '/tmp/logs', + }); + + const generatedContent = vol.readFileSync(outputFile, 'utf-8') as string; + expect(generatedContent).toMatchSnapshot(); + }); + + it('should create Gymfile without build configuration when not provided', async () => { + const mockCredentials: Credentials = { + keychainPath: '/tmp/keychain', + distributionType: DistributionType.AD_HOC, + teamId: 'TEAM123', + applicationTargetProvisioningProfile: {} as any, + targetProvisioningProfiles: { + 'com.example.app': { + bundleIdentifier: 'com.example.app', + uuid: 'test-uuid', + path: '/path/to/profile.mobileprovision', + target: 'com.example.app', + teamId: 'TEAM123', + name: 'Profile', + developerCertificate: Buffer.from('cert'), + certificateCommonName: 'iPhone Distribution', + distributionType: DistributionType.AD_HOC, + }, + }, + }; + + const outputFile = '/tmp/Gymfile'; + + await createGymfileForArchiveBuild({ + outputFile, + credentials: mockCredentials, + scheme: 'TestScheme', + outputDirectory: '/tmp/output', + clean: false, + logsDirectory: '/tmp/logs', + }); + + const generatedContent = vol.readFileSync(outputFile, 'utf-8') as string; + expect(generatedContent).toMatchSnapshot(); + }); + + it('should include iCloud container environment when provided in entitlements', async () => { + const mockCredentials: Credentials = { + keychainPath: '/tmp/keychain', + distributionType: DistributionType.APP_STORE, + teamId: 'TEAM123', + applicationTargetProvisioningProfile: {} as any, + targetProvisioningProfiles: { + 'com.example.app': { + bundleIdentifier: 'com.example.app', + uuid: 'test-uuid', + path: '/path/to/profile.mobileprovision', + target: 'com.example.app', + teamId: 'TEAM123', + name: 'Profile', + developerCertificate: Buffer.from('cert'), + certificateCommonName: 'iPhone Distribution', + distributionType: DistributionType.APP_STORE, + }, + }, + }; + + const outputFile = '/tmp/Gymfile'; + + await createGymfileForArchiveBuild({ + outputFile, + credentials: mockCredentials, + scheme: 'MyApp', + buildConfiguration: 'Release', + outputDirectory: '/tmp/output', + clean: true, + logsDirectory: '/tmp/logs', + entitlements: { + 'com.apple.developer.icloud-container-environment': 'Production', + }, + }); + + const generatedContent = vol.readFileSync(outputFile, 'utf-8') as string; + expect(generatedContent).toMatchSnapshot(); + }); + + it('should handle multiple provisioning profiles correctly', async () => { + const mockCredentials: Credentials = { + keychainPath: '/tmp/keychain', + distributionType: DistributionType.ENTERPRISE, + teamId: 'TEAM123', + applicationTargetProvisioningProfile: {} as any, + targetProvisioningProfiles: { + 'com.example.app': { + bundleIdentifier: 'com.example.app', + uuid: 'main-app-uuid', + path: '/path/to/main.mobileprovision', + target: 'com.example.app', + teamId: 'TEAM123', + name: 'Main Profile', + developerCertificate: Buffer.from('cert'), + certificateCommonName: 'iPhone Distribution', + distributionType: DistributionType.ENTERPRISE, + }, + 'com.example.app.widget': { + bundleIdentifier: 'com.example.app.widget', + uuid: 'widget-uuid', + path: '/path/to/widget.mobileprovision', + target: 'com.example.app.widget', + teamId: 'TEAM123', + name: 'Widget Profile', + developerCertificate: Buffer.from('cert'), + certificateCommonName: 'iPhone Distribution', + distributionType: DistributionType.ENTERPRISE, + }, + 'com.example.app.extension': { + bundleIdentifier: 'com.example.app.extension', + uuid: 'extension-uuid', + path: '/path/to/extension.mobileprovision', + target: 'com.example.app.extension', + teamId: 'TEAM123', + name: 'Extension Profile', + developerCertificate: Buffer.from('cert'), + certificateCommonName: 'iPhone Distribution', + distributionType: DistributionType.ENTERPRISE, + }, + }, + }; + + const outputFile = '/tmp/Gymfile'; + + await createGymfileForArchiveBuild({ + outputFile, + credentials: mockCredentials, + scheme: 'MyApp', + outputDirectory: '/tmp/output', + clean: true, + logsDirectory: '/tmp/logs', + }); + + const generatedContent = vol.readFileSync(outputFile, 'utf-8') as string; + expect(generatedContent).toMatchSnapshot(); + }); + }); + + describe('createGymfileForSimulatorBuild', () => { + it('should create Gymfile with all simulator variables substituted correctly', async () => { + const outputFile = '/tmp/Gymfile'; + + await createGymfileForSimulatorBuild({ + outputFile, + scheme: 'MyApp', + buildConfiguration: 'Debug', + derivedDataPath: '/tmp/derived-data', + clean: true, + logsDirectory: '/tmp/logs', + simulatorDestination: 'generic/platform=iOS Simulator', + }); + + const generatedContent = vol.readFileSync(outputFile, 'utf-8') as string; + expect(generatedContent).toMatchSnapshot(); + }); + + it('should create Gymfile without configuration when not provided', async () => { + const outputFile = '/tmp/Gymfile'; + + await createGymfileForSimulatorBuild({ + outputFile, + scheme: 'TestApp', + derivedDataPath: '/tmp/derived', + clean: false, + logsDirectory: '/tmp/logs', + simulatorDestination: 'platform=iOS Simulator,name=iPhone 15', + }); + + const generatedContent = vol.readFileSync(outputFile, 'utf-8') as string; + expect(generatedContent).toMatchSnapshot(); + }); + + it('should handle tvOS simulator destination', async () => { + const outputFile = '/tmp/Gymfile'; + + await createGymfileForSimulatorBuild({ + outputFile, + scheme: 'MyTVApp', + buildConfiguration: 'Debug', + derivedDataPath: '/tmp/derived', + clean: true, + logsDirectory: '/tmp/logs', + simulatorDestination: 'generic/platform=tvOS Simulator', + }); + + const generatedContent = vol.readFileSync(outputFile, 'utf-8') as string; + expect(generatedContent).toMatchSnapshot(); + }); + }); +}); diff --git a/packages/build-tools/src/ios/__tests__/xcodeEnv.test.ts b/packages/build-tools/src/ios/__tests__/xcodeEnv.test.ts new file mode 100644 index 0000000000..96b05e944e --- /dev/null +++ b/packages/build-tools/src/ios/__tests__/xcodeEnv.test.ts @@ -0,0 +1,39 @@ +import { Ios } from '@expo/eas-build-job'; +import { bunyan } from '@expo/logger'; +import fs from 'fs-extra'; +import { vol } from 'memfs'; +import { instance, mock, when, verify } from 'ts-mockito'; + +import { BuildContext } from '../../context'; +import { deleteXcodeEnvLocalIfExistsAsync } from '../xcodeEnv'; + +jest.mock('fs'); + +afterEach(() => { + vol.reset(); +}); + +describe(deleteXcodeEnvLocalIfExistsAsync, () => { + it('removes ios/.xcode.env.local if exists + calls ctx.markBuildPhaseHasWarnings', async () => { + vol.fromJSON( + { + 'ios/.xcode.env': '# lorem ipsum', + 'ios/.xcode.env.local': '# lorem ipsum', + }, + '/app' + ); + + const mockCtx = mock>(); + when(mockCtx.getReactNativeProjectDirectory()).thenReturn('/app'); + when(mockCtx.logger).thenReturn(instance(mock())); + const ctx = instance(mockCtx); + + await deleteXcodeEnvLocalIfExistsAsync(ctx); + + await expect(fs.pathExists('/app/ios/.xcode.env')).resolves.toBe(true); + await expect(fs.pathExists('/app/ios/.xcode.env.local')).resolves.toBe(false); + + // eslint-disable-next-line @typescript-eslint/no-confusing-void-expression + verify(mockCtx.markBuildPhaseHasWarnings()).called(); + }); +}); diff --git a/packages/build-tools/src/ios/configure.ts b/packages/build-tools/src/ios/configure.ts new file mode 100644 index 0000000000..8432665ce4 --- /dev/null +++ b/packages/build-tools/src/ios/configure.ts @@ -0,0 +1,130 @@ +import path from 'path'; + +import { IOSConfig } from '@expo/config-plugins'; +import { Ios } from '@expo/eas-build-job'; +import uniq from 'lodash/uniq'; +import fs from 'fs-extra'; +import plist from '@expo/plist'; + +import { BuildContext } from '../context'; + +import { Credentials } from './credentials/manager'; + +async function configureXcodeProject( + ctx: BuildContext, + { + credentials, + buildConfiguration, + }: { + credentials: Credentials; + buildConfiguration: string; + } +): Promise { + ctx.logger.info('Configuring Xcode project'); + await configureCredentialsAsync(ctx, { + credentials, + buildConfiguration, + }); + const { version } = ctx.job; + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + if (version?.appVersion || version?.buildNumber) { + await updateVersionsAsync(ctx, { + targetNames: Object.keys(credentials.targetProvisioningProfiles), + buildConfiguration, + }); + } +} + +async function configureCredentialsAsync( + ctx: BuildContext, + { + credentials, + buildConfiguration, + }: { + credentials: Credentials; + buildConfiguration: string; + } +): Promise { + const targetNames = Object.keys(credentials.targetProvisioningProfiles); + for (const targetName of targetNames) { + const profile = credentials.targetProvisioningProfiles[targetName]; + ctx.logger.info( + `Assigning provisioning profile '${profile.name}' (Apple Team ID: ${profile.teamId}) to target '${targetName}'` + ); + IOSConfig.ProvisioningProfile.setProvisioningProfileForPbxproj( + ctx.getReactNativeProjectDirectory(), + { + targetName, + profileName: profile.name, + appleTeamId: profile.teamId, + buildConfiguration, + } + ); + } +} + +async function updateVersionsAsync( + ctx: BuildContext, + { + targetNames, + buildConfiguration, + }: { + targetNames: string[]; + buildConfiguration: string; + } +): Promise { + const project = IOSConfig.XcodeUtils.getPbxproj(ctx.getReactNativeProjectDirectory()); + const iosDir = path.join(ctx.getReactNativeProjectDirectory(), 'ios'); + + const infoPlistPaths: string[] = []; + for (const targetName of targetNames) { + const xcBuildConfiguration = IOSConfig.Target.getXCBuildConfigurationFromPbxproj(project, { + targetName, + buildConfiguration, + }); + const infoPlist = xcBuildConfiguration.buildSettings.INFOPLIST_FILE; + if (infoPlist) { + const evaluatedInfoPlistPath = trimQuotes( + evaluateTemplateString(infoPlist, { + SRCROOT: iosDir, + }) + ); + const absolutePath = path.isAbsolute(evaluatedInfoPlistPath) + ? evaluatedInfoPlistPath + : path.join(iosDir, evaluatedInfoPlistPath); + infoPlistPaths.push(path.normalize(absolutePath)); + } + } + const uniqueInfoPlistPaths = uniq(infoPlistPaths); + for (const infoPlistPath of uniqueInfoPlistPaths) { + ctx.logger.info(`Updating versions in ${infoPlistPath}`); + const infoPlistRaw = await fs.readFile(infoPlistPath, 'utf-8'); + const infoPlist = plist.parse(infoPlistRaw) as IOSConfig.InfoPlist; + if (ctx.job.version?.buildNumber) { + infoPlist.CFBundleVersion = ctx.job.version?.buildNumber; + } + if (ctx.job.version?.appVersion) { + infoPlist.CFBundleShortVersionString = ctx.job.version?.appVersion; + } + await fs.writeFile(infoPlistPath, plist.build(infoPlist)); + } +} + +function trimQuotes(s: string): string { + return s?.startsWith('"') && s.endsWith('"') ? s.slice(1, -1) : s; +} + +export function evaluateTemplateString(s: string, buildSettings: Record): string { + // necessary because buildSettings might be XCBuildConfiguration['buildSettings'] which is not a plain object + const vars = { ...buildSettings }; + return s.replace(/\$\((\w+)\)/g, (match, key) => { + if (vars.hasOwnProperty(key)) { + const value = String(vars[key]); + return trimQuotes(value); + } else { + return match; + } + }); +} + +export { configureXcodeProject }; diff --git a/packages/build-tools/src/ios/credentials/__integration-tests__/keychain.test.ios.ts b/packages/build-tools/src/ios/credentials/__integration-tests__/keychain.test.ios.ts new file mode 100644 index 0000000000..cafca3bebc --- /dev/null +++ b/packages/build-tools/src/ios/credentials/__integration-tests__/keychain.test.ios.ts @@ -0,0 +1,74 @@ +import os from 'os'; +import path from 'path'; + +import { Ios } from '@expo/eas-build-job'; +import { createLogger } from '@expo/logger'; +import fs from 'fs-extra'; +import { v4 as uuid } from 'uuid'; + +import { BuildContext } from '../../../context'; +import Keychain from '../keychain'; +import { distributionCertificate } from '../__tests__/fixtures'; + +const mockLogger = createLogger({ name: 'mock-logger' }); + +jest.setTimeout(60 * 1000); + +// Those are in fact "real" tests in that they really execute the `fastlane` commands. +// We need the JS code to modify the file system, so we need to mock it. +jest.unmock('fs'); + +let ctx: BuildContext; + +describe('Keychain class', () => { + describe('ensureCertificateImported method', () => { + let keychain: Keychain; + const certificatePath = path.join(os.tmpdir(), `cert-${uuid()}.p12`); + + beforeAll(async () => { + await fs.writeFile( + certificatePath, + new Uint8Array(Buffer.from(distributionCertificate.dataBase64, 'base64')) + ); + }); + + afterAll(async () => { + await fs.remove(certificatePath); + }); + + beforeEach(async () => { + ctx = new BuildContext({ projectRootDirectory: '.' } as Ios.Job, { + workingdir: '/workingdir', + logBuffer: { getLogs: () => [], getPhaseLogs: () => [] }, + logger: mockLogger, + env: {}, + uploadArtifact: jest.fn(), + }); + keychain = new Keychain(ctx); + await keychain.create(); + }); + + afterEach(async () => { + await keychain.destroy(); + }); + + it("should throw an error if the certificate hasn't been imported", async () => { + await expect( + keychain.ensureCertificateImported( + distributionCertificate.teamId, + distributionCertificate.fingerprint + ) + ).rejects.toThrowError(/hasn't been imported successfully/); + }); + + it("shouldn't throw any error if the certificate has been imported successfully", async () => { + await keychain.importCertificate(certificatePath, distributionCertificate.password); + await expect( + keychain.ensureCertificateImported( + distributionCertificate.teamId, + distributionCertificate.fingerprint + ) + ).resolves.not.toThrow(); + }); + }); +}); diff --git a/packages/build-tools/src/ios/credentials/__integration-tests__/manager.test.ios.ts b/packages/build-tools/src/ios/credentials/__integration-tests__/manager.test.ios.ts new file mode 100644 index 0000000000..e1a557b48b --- /dev/null +++ b/packages/build-tools/src/ios/credentials/__integration-tests__/manager.test.ios.ts @@ -0,0 +1,95 @@ +import assert from 'assert'; +import { randomUUID } from 'crypto'; + +import { ArchiveSourceType, Ios, Platform, Workflow } from '@expo/eas-build-job'; +import { BuildMode, BuildTrigger } from '@expo/eas-build-job/dist/common'; +import { createLogger } from '@expo/logger'; + +import { BuildContext } from '../../../context'; +import { distributionCertificate, provisioningProfile } from '../__tests__/fixtures'; +import IosCredentialsManager from '../manager'; + +jest.setTimeout(60 * 1000); + +// Those are in fact "real" tests in that they really execute the `fastlane` commands. +// We need the JS code to modify the file system, so we need to mock it. +jest.unmock('fs'); + +const mockLogger = createLogger({ name: 'mock-logger' }); + +const iosCredentials: Ios.BuildCredentials = { + testapp: { + provisioningProfileBase64: '', + distributionCertificate: { + dataBase64: '', + password: '', + }, + }, +}; + +function createTestIosJob({ + buildCredentials = iosCredentials, +}: { + buildCredentials?: Ios.BuildCredentials; +} = {}): Ios.Job { + return { + mode: BuildMode.BUILD, + platform: Platform.IOS, + triggeredBy: BuildTrigger.EAS_CLI, + type: Workflow.GENERIC, + projectArchive: { + type: ArchiveSourceType.URL, + url: 'https://turtle-v2-test-fixtures.s3.us-east-2.amazonaws.com/project.tar.gz', + }, + scheme: 'turtlebareproj', + buildConfiguration: 'Release', + applicationArchivePath: './ios/build/*.ipa', + projectRootDirectory: '.', + cache: { + clear: false, + disabled: false, + paths: [], + }, + secrets: { + buildCredentials, + }, + appId: randomUUID(), + initiatingUserId: randomUUID(), + }; +} + +describe(IosCredentialsManager, () => { + describe('.prepare', () => { + it('should prepare credentials for the build process', async () => { + const targetName = 'testapp'; + const job = createTestIosJob({ + buildCredentials: { + [targetName]: { + distributionCertificate, + provisioningProfileBase64: provisioningProfile.dataBase64, + }, + }, + }); + const ctx = new BuildContext(job, { + workingdir: '/workingdir', + logBuffer: { getLogs: () => [], getPhaseLogs: () => [] }, + logger: mockLogger, + env: {}, + uploadArtifact: jest.fn(), + }); + const manager = new IosCredentialsManager(ctx); + const credentials = await manager.prepare(); + await manager.cleanUp(); + + assert(credentials, 'credentials must be defined'); + + expect(credentials.teamId).toBe('QL76XYH73P'); + expect(credentials.distributionType).toBe('app-store'); + + const profile = credentials.targetProvisioningProfiles[targetName]; + expect(profile.bundleIdentifier).toBe('org.reactjs.native.example.testapp.turtlev2'); + expect(profile.distributionType).toBe('app-store'); + expect(profile.teamId).toBe('QL76XYH73P'); + }); + }); +}); diff --git a/packages/build-tools/src/ios/credentials/__tests__/distributionCertificate.test.ios.ts b/packages/build-tools/src/ios/credentials/__tests__/distributionCertificate.test.ios.ts new file mode 100644 index 0000000000..0e9f16d108 --- /dev/null +++ b/packages/build-tools/src/ios/credentials/__tests__/distributionCertificate.test.ios.ts @@ -0,0 +1,38 @@ +import fs from 'fs-extra'; +import { vol } from 'memfs'; + +import { getFingerprint, getCommonName } from '../distributionCertificate'; + +import { distributionCertificate } from './fixtures'; + +describe('distributionCertificate module', () => { + describe(getFingerprint, () => { + it('calculates the certificate fingerprint', () => { + const fingerprint = getFingerprint({ + dataBase64: distributionCertificate.dataBase64, + password: distributionCertificate.password, + }); + expect(fingerprint).toEqual(distributionCertificate.fingerprint); + }); + + it('should throw an error if the password is incorrect', () => { + expect(() => { + getFingerprint({ + dataBase64: distributionCertificate.dataBase64, + password: 'incorrect', + }); + }).toThrowError(/password.*invalid/); + }); + }); + describe(getCommonName, () => { + it('returns cert common name', async () => { + vol.fromNestedJSON({ '/tmp': {} }); + const commonName = getCommonName({ + dataBase64: distributionCertificate.dataBase64, + password: distributionCertificate.password, + }); + await fs.writeFile('/tmp/a', commonName, 'utf-8'); + expect(commonName).toBe(distributionCertificate.commonName); + }); + }); +}); diff --git a/packages/build-tools/src/ios/credentials/__tests__/fixtures.ts b/packages/build-tools/src/ios/credentials/__tests__/fixtures.ts new file mode 100644 index 0000000000..e08105515c --- /dev/null +++ b/packages/build-tools/src/ios/credentials/__tests__/fixtures.ts @@ -0,0 +1,34 @@ +interface DistributionCertificateData { + dataBase64: string; + password: string; + serialNumber: string; + fingerprint: string; + teamId: string; + commonName: string; +} + +interface ProvisioningProfileData { + id?: string; + dataBase64: string; + certFingerprint: string; +} + +// TODO: regenerate after Jun 4, 2025 +// this certificate is invalidated +export const distributionCertificate: DistributionCertificateData = { + dataBase64: `MIIL8AIBAzCCC7YGCSqGSIb3DQEHAaCCC6cEggujMIILnzCCBj8GCSqGSIb3DQEHAaCCBjAEggYsMIIGKDCCBiQGCyqGSIb3DQEMCgEDoIIF1TCCBdEGCiqGSIb3DQEJFgGgggXBBIIFvTCCBbkwggShoAMCAQICEAF/s+5UwyPCBGg0yp7+wokwDQYJKoZIhvcNAQELBQAwdTFEMEIGA1UEAww7QXBwbGUgV29ybGR3aWRlIERldmVsb3BlciBSZWxhdGlvbnMgQ2VydGlmaWNhdGlvbiBBdXRob3JpdHkxCzAJBgNVBAsMAkczMRMwEQYDVQQKDApBcHBsZSBJbmMuMQswCQYDVQQGEwJVUzAeFw0yNTA2MTAwODIyNTBaFw0yNjA2MTAwODIyNDlaMIGUMRowGAYKCZImiZPyLGQBAQwKUUw3NlhZSDczUDE6MDgGA1UEAwwxaVBob25lIERpc3RyaWJ1dGlvbjogQWxpY2phIFdhcmNoYcWCIChRTDc2WFlINzNQKTETMBEGA1UECwwKUUw3NlhZSDczUDEYMBYGA1UECgwPQWxpY2phIFdhcmNoYcWCMQswCQYDVQQGEwJVUzCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAM45oAP6+kc1OuyEx99inQVKKxk5jhwD4pSIxGl4L4PoOoCvc3ao++IMg5J3Euv9k/78jV//FurL3mQEIjg5HcjIYsXldJk0RFehsswQSV//8T0aFVjFweM51wsxpe7iCb/uVwYJVnu+g/DK+wKi2IWaNOHnSdwNOODPxftzbvtgEm7RFNxtW5boE3ulmKopvAcZR3YD7st13F6VBnJYAn5m+9OKSqSRCF+u8yQ7XRzSj2xXfrR/bPDW2fianIQPhveui1L65jt1s1z30yoO97FXenE+PidZihrVcBAV8myMmi5lX5jPICP2D6npbX5t9gQswIdhjC6WpnybL/PzSvECAwEAAaOCAiMwggIfMAwGA1UdEwEB/wQCMAAwHwYDVR0jBBgwFoAUCf7AFZD5r2QKkhK5JihjDJfsp7IwcAYIKwYBBQUHAQEEZDBiMC0GCCsGAQUFBzAChiFodHRwOi8vY2VydHMuYXBwbGUuY29tL3d3ZHJnMy5kZXIwMQYIKwYBBQUHMAGGJWh0dHA6Ly9vY3NwLmFwcGxlLmNvbS9vY3NwMDMtd3dkcmczMDIwggEeBgNVHSAEggEVMIIBETCCAQ0GCSqGSIb3Y2QFATCB/zCBwwYIKwYBBQUHAgIwgbYMgbNSZWxpYW5jZSBvbiB0aGlzIGNlcnRpZmljYXRlIGJ5IGFueSBwYXJ0eSBhc3N1bWVzIGFjY2VwdGFuY2Ugb2YgdGhlIHRoZW4gYXBwbGljYWJsZSBzdGFuZGFyZCB0ZXJtcyBhbmQgY29uZGl0aW9ucyBvZiB1c2UsIGNlcnRpZmljYXRlIHBvbGljeSBhbmQgY2VydGlmaWNhdGlvbiBwcmFjdGljZSBzdGF0ZW1lbnRzLjA3BggrBgEFBQcCARYraHR0cHM6Ly93d3cuYXBwbGUuY29tL2NlcnRpZmljYXRlYXV0aG9yaXR5LzAWBgNVHSUBAf8EDDAKBggrBgEFBQcDAzAdBgNVHQ4EFgQUpBXWH7c6kvuNVDtbUg5ABmmIi4UwDgYDVR0PAQH/BAQDAgeAMBMGCiqGSIb3Y2QGAQQBAf8EAgUAMA0GCSqGSIb3DQEBCwUAA4IBAQAurJLhdKe0U02JhpX65LUUJSyk1WmholtK1DECIqL6RD6f80toaLpmGlvZ0VzZgn5ceSPgIpqcQGcgOqr4LdDnBj/C3y1mfcvgaAHD6ksKKkwmU79t2bSMRrLi4SdtPFrXePMcn8py4awMeGOYi9Z1to/Bh/x6+H0mkXVs2D0R1+O+pZxKXjT9Eg96o2p9j7RRuMVQ1yT6pKT7q1T9OEmHum/Llld6uS7Eg+Uyj/N2aZvJrQRRaSlsOwIc7IR7a9bkSHC2RSKmKVKdY0wuxOAz2TIqRMkDbxsJMqxSJdj0FyCLyZkGQT72EMDHTfbA9054mvn/IxJ5YsEv37yiOvLfMTwwIwYJKoZIhvcNAQkVMRYEFGH4WlEGNyq60eae8sEVcSzBwXAsMBUGCSqGSIb3DQEJFDEIHgYAawBlAHkwggVYBgkqhkiG9w0BBwGgggVJBIIFRTCCBUEwggU9BgsqhkiG9w0BDAoBAqCCBO4wggTqMBwGCiqGSIb3DQEMAQMwDgQIoJXy6GSRRgACAggABIIEyBKWMl2OAyV7nWfoWPFy5+Qr3wookZAonzqTLml+lWg6EyopErj3S5PBI2LstwNLcapY3BG+ez5TUs1sPEbC6WtpNivJb7mURb8nw/+dlIojQS4DSqoIN4VXRI8uBbXf2jl09LC9qoBU9as1USqDRMOpIgMbvA4JPBtUjgxyeSz2okVeKvoZ92ynrrx+Uv4NCpHIPUthNcUUbPg0NZtLoK+JqnxnkbSKaKAaCweGRz18cH9R2gM0u3LVIduXIFw5JeUNakh36fD8Q4Z+Heby//wu+uM12z16h+4DJfp+YpeJaLj0up6+qSh2A/hRjC7FRHY3d9M8t6+LcWXHqv5mI8Wf0k6/3UFY5OnQGVDRmPBSLceC2bxFJPlSHX4qeic6IuYHocn9eHZgIOZuA8X3s87iBW6WHnwLraMThjFgv8kZYZfLO6fEX49zxMVUt/tllZA6xroWuPGb1+r313bteqlp6r/XkRmKDN5f0ypgvTM7doSTzT5sLw8TvF0B0Kl21a46EWyYm3aINtQfUGrtgMDEdhLXiKMzx+lRe87J3kWNE/gDCrJOmlRiOWg0zWqEzghO7baFRmUheZ4TBbP7oCBwZ1TAa/wzwaQ3+gi4ycCKs5YVJTO0Z8ld5Sjyqu050D4ElpGt+EQTlSqvk4157RPvQYZFzSpsmpbdhhUeWrmXViHt+8fwJC4K4mbZZzjJU1JyxlgJYrh4dSAmdgoQJd4VQFcIt19qZaCLZ0HCTdNHtbMuLaFKWg+FPiAUcvLjv5e+UC3+dYuR3IoncuyveK8bcE0MEIbEgAgFApDmZi/EUlnmlOSaGH4aKDBYuWODFEwSWI+ajrpwHDMBAM+zO3bDSDbTGm/AHtJOQ+cfgfvzkr/1jUkFuMfyqCFaSv+VK7yPBb5B2n4iLhh9jTFOztC1oVZlT0qZM99K9wgAFyo0iD975BEkhtl6D+alRjU3gyQ+2lcy3IHwE2MXYI9sH19gUdx6gu1kFCP4ukw1fo81aqHMiDSNjHCSF6ydsw2m8wCV+Zd1nws2tA6HehHFi8jmnKMaJ0fKHymVoDKHjSUxWHJRFVBhPUUOgxhaNVDEQBFuAqRa8Emk2XFNCx5BQCMaJsTz8mA4NpKuEh1NyitiVVN4ezPfkQ/4KKlC//y3nInR1U3qMRc14g+uUuMSmcQJrqwbF/RCxA7lAivOzKwN7N01iEjo9u6FLn4yaVd8Y+0h92+0nYzx7I5Gr+KrGGbMY35+ytntZ4/sJ9c3WR0gJUVPGgYy3tLBS4UOaa/iH0c4R4lHEJp+Wm/XrMQvHfNTQuQrcjKW4M/6dduXtcCOqstsT2SnZnZXkFH9SU9TdP7PFVXFeyer8b86fPmcR1cXxCfaNGVzWRXktFzseM31c4ZQK3sxKk8OvH6iUx1QJzjc3NGI7jczGk0HO7NSLxeF6aaeJ+PivICtNg/X95NVfCCqUrrkrsuPTYQOOeISHTELL8CAL6DC1V11LcFYEqaKwovWDyrdqu1LLA6NHWbrvdPvQosbB2Lq3vB76a2x/rIenQiAEyluB6aK7AEdfd1S+AQT5QpHwL5HayYtW57FF91yfrmHCwwmuBB4Cj1beE4InjwvNyxIcjxSvDUfl+LplIh5kIimOjE8MCMGCSqGSIb3DQEJFTEWBBRh+FpRBjcqutHmnvLBFXEswcFwLDAVBgkqhkiG9w0BCRQxCB4GAGsAZQB5MDEwITAJBgUrDgMCGgUABBTkYbJeShZO4HeTNWVcl/DNvX0i8gQIYL7sJfr+fCcCAggA +`, + password: 'uCo32FiOY1sk5WLB3MjfZA==', + serialNumber: '017FB3EE54C323C2046834CA9EFEC289', + fingerprint: '61F85A5106372ABAD1E69EF2C115712CC1C1702C', + teamId: 'QL76XYH73P', + commonName: 'iPhone Distribution: Alicja Warchał (QL76XYH73P)', +}; + +// TODO: regenerate after Jun 4, 2025 +// this provisioning profile is invalidated +export const provisioningProfile: ProvisioningProfileData = { + id: '3D2QDBHRRD', + dataBase64: `MIIwKQYJKoZIhvcNAQcCoIIwGjCCMBYCAQExCzAJBgUrDgMCGgUAMIIgNgYJKoZIhvcNAQcBoIIgJwSCICM8P3htbCB2ZXJzaW9uPSIxLjAiIGVuY29kaW5nPSJVVEYtOCI/Pgo8IURPQ1RZUEUgcGxpc3QgUFVCTElDICItLy9BcHBsZS8vRFREIFBMSVNUIDEuMC8vRU4iICJodHRwOi8vd3d3LmFwcGxlLmNvbS9EVERzL1Byb3BlcnR5TGlzdC0xLjAuZHRkIj4KPHBsaXN0IHZlcnNpb249IjEuMCI+CjxkaWN0PgoJPGtleT5BcHBJRE5hbWU8L2tleT4KCTxzdHJpbmc+dGVzdGFwcCAzMTBlMGIzNzcxODQ5NWNlZWU0NDc3MGI2NzkzZWVkZjwvc3RyaW5nPgoJPGtleT5BcHBsaWNhdGlvbklkZW50aWZpZXJQcmVmaXg8L2tleT4KCTxhcnJheT4KCTxzdHJpbmc+UUw3NlhZSDczUDwvc3RyaW5nPgoJPC9hcnJheT4KCTxrZXk+Q3JlYXRpb25EYXRlPC9rZXk+Cgk8ZGF0ZT4yMDI1LTA2LTEwVDA4OjMzOjA0WjwvZGF0ZT4KCTxrZXk+UGxhdGZvcm08L2tleT4KCTxhcnJheT4KCQk8c3RyaW5nPmlPUzwvc3RyaW5nPgoJCTxzdHJpbmc+eHJPUzwvc3RyaW5nPgoJCTxzdHJpbmc+dmlzaW9uT1M8L3N0cmluZz4KCTwvYXJyYXk+Cgk8a2V5PklzWGNvZGVNYW5hZ2VkPC9rZXk+Cgk8ZmFsc2UvPgoJPGtleT5EZXZlbG9wZXJDZXJ0aWZpY2F0ZXM8L2tleT4KCTxhcnJheT4KCQk8ZGF0YT5NSUlGdVRDQ0JLR2dBd0lCQWdJUUFYK3o3bFRESThJRWFEVEtudjdDaVRBTkJna3Foa2lHOXcwQkFRc0ZBREIxTVVRd1FnWURWUVFERER0QmNIQnNaU0JYYjNKc1pIZHBaR1VnUkdWMlpXeHZjR1Z5SUZKbGJHRjBhVzl1Y3lCRFpYSjBhV1pwWTJGMGFXOXVJRUYxZEdodmNtbDBlVEVMTUFrR0ExVUVDd3dDUnpNeEV6QVJCZ05WQkFvTUNrRndjR3hsSUVsdVl5NHhDekFKQmdOVkJBWVRBbFZUTUI0WERUSTFNRFl4TURBNE1qSTFNRm9YRFRJMk1EWXhNREE0TWpJME9Wb3dnWlF4R2pBWUJnb0praWFKay9Jc1pBRUJEQXBSVERjMldGbElOek5RTVRvd09BWURWUVFERERGcFVHaHZibVVnUkdsemRISnBZblYwYVc5dU9pQkJiR2xqYW1FZ1YyRnlZMmhoeFlJZ0tGRk1OelpZV1VnM00xQXBNUk13RVFZRFZRUUxEQXBSVERjMldGbElOek5RTVJnd0ZnWURWUVFLREE5QmJHbGphbUVnVjJGeVkyaGh4WUl4Q3pBSkJnTlZCQVlUQWxWVE1JSUJJakFOQmdrcWhraUc5dzBCQVFFRkFBT0NBUThBTUlJQkNnS0NBUUVBemptZ0EvcjZSelU2N0lUSDMyS2RCVW9yR1RtT0hBUGlsSWpFYVhndmcrZzZnSzl6ZHFqNzRneURrbmNTNi8yVC92eU5YLzhXNnN2ZVpBUWlPRGtkeU1oaXhlVjBtVFJFVjZHeXpCQkpYLy94UFJvVldNWEI0em5YQ3pHbDd1SUp2KzVYQmdsV2U3NkQ4TXI3QXFMWWhabzA0ZWRKM0EwNDRNL0YrM051KzJBU2J0RVUzRzFibHVnVGU2V1lxaW04QnhsSGRnUHV5M1hjWHBVR2NsZ0NmbWI3MDRwS3BKRUlYNjd6SkR0ZEhOS1BiRmQrdEg5czhOYlorSnFjaEErRzk2NkxVdnJtTzNXelhQZlRLZzczc1ZkNmNUNCtKMW1LR3RWd0VCWHliSXlhTG1WZm1NOGdJL1lQcWVsdGZtMzJCQ3pBaDJHTUxwYW1mSnN2OC9OSzhRSURBUUFCbzRJQ0l6Q0NBaDh3REFZRFZSMFRBUUgvQkFJd0FEQWZCZ05WSFNNRUdEQVdnQlFKL3NBVmtQbXZaQXFTRXJrbUtHTU1sK3luc2pCd0JnZ3JCZ0VGQlFjQkFRUmtNR0l3TFFZSUt3WUJCUVVITUFLR0lXaDBkSEE2THk5alpYSjBjeTVoY0hCc1pTNWpiMjB2ZDNka2NtY3pMbVJsY2pBeEJnZ3JCZ0VGQlFjd0FZWWxhSFIwY0RvdkwyOWpjM0F1WVhCd2JHVXVZMjl0TDI5amMzQXdNeTEzZDJSeVp6TXdNakNDQVI0R0ExVWRJQVNDQVJVd2dnRVJNSUlCRFFZSktvWklodmRqWkFVQk1JSC9NSUhEQmdnckJnRUZCUWNDQWpDQnRneUJzMUpsYkdsaGJtTmxJRzl1SUhSb2FYTWdZMlZ5ZEdsbWFXTmhkR1VnWW5rZ1lXNTVJSEJoY25SNUlHRnpjM1Z0WlhNZ1lXTmpaWEIwWVc1alpTQnZaaUIwYUdVZ2RHaGxiaUJoY0hCc2FXTmhZbXhsSUhOMFlXNWtZWEprSUhSbGNtMXpJR0Z1WkNCamIyNWthWFJwYjI1eklHOW1JSFZ6WlN3Z1kyVnlkR2xtYVdOaGRHVWdjRzlzYVdONUlHRnVaQ0JqWlhKMGFXWnBZMkYwYVc5dUlIQnlZV04wYVdObElITjBZWFJsYldWdWRITXVNRGNHQ0NzR0FRVUZCd0lCRml0b2RIUndjem92TDNkM2R5NWhjSEJzWlM1amIyMHZZMlZ5ZEdsbWFXTmhkR1ZoZFhSb2IzSnBkSGt2TUJZR0ExVWRKUUVCL3dRTU1Bb0dDQ3NHQVFVRkJ3TURNQjBHQTFVZERnUVdCQlNrRmRZZnR6cVMrNDFVTzF0U0RrQUdhWWlMaFRBT0JnTlZIUThCQWY4RUJBTUNCNEF3RXdZS0tvWklodmRqWkFZQkJBRUIvd1FDQlFBd0RRWUpLb1pJaHZjTkFRRUxCUUFEZ2dFQkFDNnNrdUYwcDdSVFRZbUdsZnJrdFJRbExLVFZhYUdpVzByVU1RSWlvdnBFUHAvelMyaG91bVlhVzluUlhObUNmbHg1SStBaW1weEFaeUE2cXZndDBPY0dQOExmTFdaOXkrQm9BY1BxU3dvcVRDWlR2MjNadEl4R3N1TGhKMjA4V3RkNDh4eWZ5bkxockF4NFk1aUwxblcyajhHSC9IcjRmU2FSZFd6WVBSSFg0NzZsbkVwZU5QMFNEM3FqYW4yUHRGRzR4VkRYSlBxa3BQdXJWUDA0U1llNmI4dVdWM3E1THNTRDVUS1A4M1pwbThtdEJGRnBLV3c3QWh6c2hIdHIxdVJJY0xaRklxWXBVcDFqVEM3RTREUFpNaXBFeVFOdkd3a3lyRklsMlBRWElJdkptUVpCUHZZUXdNZE45c0QzVG5pYStmOGpFbmxpd1MvZnZLSTY4dDg9PC9kYXRhPgoJPC9hcnJheT4KCgk8a2V5PkRFUi1FbmNvZGVkLVByb2ZpbGU8L2tleT4KCTxkYXRhPk1JSU5pd1lKS29aSWh2Y05BUWNDb0lJTmZEQ0NEWGdDQVFFeER6QU5CZ2xnaGtnQlpRTUVBZ0VGQURDQ0EwWUdDU3FHU0liM0RRRUhBYUNDQXpjRWdnTXpNWUlETHpBTURBZFdaWEp6YVc5dUFnRUJNQkFNQ2xScGJXVlViMHhwZG1VQ0FnRnNNQk1NRGtseldHTnZaR1ZOWVc1aFoyVmtBUUVBTUJzTUNGUmxZVzFPWVcxbERBOUJiR2xqYW1FZ1YyRnlZMmhoeFlJd0hRd01RM0psWVhScGIyNUVZWFJsRncweU5UQTJNVEF3T0RNek1EUmFNQjRNRGxSbFlXMUpaR1Z1ZEdsbWFXVnlNQXdNQ2xGTU56WllXVWczTTFBd0h3d09SWGh3YVhKaGRHbHZia1JoZEdVWERUSTJNRFl4TURBNE1qSTBPVm93SUF3WFVISnZabWxzWlVScGMzUnlhV0oxZEdsdmJsUjVjR1VNQlZOVVQxSkZNQ0VNQ0ZCc1lYUm1iM0p0TUJVTUEybFBVd3dFZUhKUFV3d0lkbWx6YVc5dVQxTXdLd3diUVhCd2JHbGpZWFJwYjI1SlpHVnVkR2xtYVdWeVVISmxabWw0TUF3TUNsRk1OelpZV1VnM00xQXdMQXdFVlZWSlJBd2taR05pWW1OaE1EQXRPV0psWXkwME5HUTBMVGd6WkdVdE1tSmhZek5oTkRabFlUUmlNRFVNQ1VGd2NFbEVUbUZ0WlF3b2RHVnpkR0Z3Y0NBek1UQmxNR0l6TnpjeE9EUTVOV05sWldVME5EYzNNR0kyTnprelpXVmtaakE3REJWRVpYWmxiRzl3WlhKRFpYSjBhV1pwWTJGMFpYTXdJZ1FneGNRem5wZmlZeTFiVk1HdnZNZzFjNHN3OEZ6M1NiQUFtR1owbUdmdlFqWXdYUXdFVG1GdFpReFZLbHRsZUhCdlhTQnZjbWN1Y21WaFkzUnFjeTV1WVhScGRtVXVaWGhoYlhCc1pTNTBaWE4wWVhCd0xuUjFjblJzWlhZeUlFRndjRk4wYjNKbElESXdNalF0TURZdE1EUlVNVGc2TVRFNk1qWXVOakF5V2pDQ0FRWU1ERVZ1ZEdsMGJHVnRaVzUwYzNDQjlRSUJBYkNCN3pCUURCWmhjSEJzYVdOaGRHbHZiaTFwWkdWdWRHbG1hV1Z5RERaUlREYzJXRmxJTnpOUUxtOXlaeTV5WldGamRHcHpMbTVoZEdsMlpTNWxlR0Z0Y0d4bExuUmxjM1JoY0hBdWRIVnlkR3hsZGpJd0dBd1RZbVYwWVMxeVpYQnZjblJ6TFdGamRHbDJaUUVCL3pBeERDTmpiMjB1WVhCd2JHVXVaR1YyWld4dmNHVnlMblJsWVcwdGFXUmxiblJwWm1sbGNnd0tVVXczTmxoWlNEY3pVREFUREE1blpYUXRkR0Z6YXkxaGJHeHZkd0VCQURBNURCWnJaWGxqYUdGcGJpMWhZMk5sYzNNdFozSnZkWEJ6TUI4TURGRk1OelpZV1VnM00xQXVLZ3dQWTI5dExtRndjR3hsTG5SdmEyVnVvSUlJUERDQ0FrTXdnZ0hKb0FNQ0FRSUNDQzNGL0lqU3hVdVZNQW9HQ0NxR1NNNDlCQU1ETUdjeEd6QVpCZ05WQkFNTUVrRndjR3hsSUZKdmIzUWdRMEVnTFNCSE16RW1NQ1FHQTFVRUN3d2RRWEJ3YkdVZ1EyVnlkR2xtYVdOaGRHbHZiaUJCZFhSb2IzSnBkSGt4RXpBUkJnTlZCQW9NQ2tGd2NHeGxJRWx1WXk0eEN6QUpCZ05WQkFZVEFsVlRNQjRYRFRFME1EUXpNREU0TVRrd05sb1hEVE01TURRek1ERTRNVGt3Tmxvd1p6RWJNQmtHQTFVRUF3d1NRWEJ3YkdVZ1VtOXZkQ0JEUVNBdElFY3pNU1l3SkFZRFZRUUxEQjFCY0hCc1pTQkRaWEowYVdacFkyRjBhVzl1SUVGMWRHaHZjbWwwZVRFVE1CRUdBMVVFQ2d3S1FYQndiR1VnU1c1akxqRUxNQWtHQTFVRUJoTUNWVk13ZGpBUUJnY3Foa2pPUFFJQkJnVXJnUVFBSWdOaUFBU1k2Uzg5UUhLazdaTWljb0VUSE4wUWxmSEZvMDV4M0JRVzJRN2xwZ1VxZDJSN1gwNDQwN3NjUkxWLzlSKzJNbUpkeWVtRVcwOHdUeEZhQVAxWVdBeWw5UThzVFFkSEUzWGFsNWVYYnpGYzdTdWRleUE3MkxsVTJWNlpwRHBSQ2pHalFqQkFNQjBHQTFVZERnUVdCQlM3c042aFdET0ltcVNLbWQ2K3ZldXYyc3NrcXpBUEJnTlZIUk1CQWY4RUJUQURBUUgvTUE0R0ExVWREd0VCL3dRRUF3SUJCakFLQmdncWhrak9QUVFEQXdOb0FEQmxBakVBZytuQnhCWmVHbDAwR05udDcvUnNEZ0JHUzdqZnNrWVJ4US85NW5xTW9hWnJ6c0lEMUp6MWs4WjB1R3JmcWlNVkFqQnRab29ReXRRTjFFL05qVU0rdElwanBUTnU0MjNhRjdka0g4aFRKdm1JWW5RNUN4ZGJ5MUdvRE9nWUErZWlzaWd3Z2dMbU1JSUNiYUFEQWdFQ0FnZ3pEZTc0djB4b0xqQUtCZ2dxaGtqT1BRUURBekJuTVJzd0dRWURWUVFEREJKQmNIQnNaU0JTYjI5MElFTkJJQzBnUnpNeEpqQWtCZ05WQkFzTUhVRndjR3hsSUVObGNuUnBabWxqWVhScGIyNGdRWFYwYUc5eWFYUjVNUk13RVFZRFZRUUtEQXBCY0hCc1pTQkpibU11TVFzd0NRWURWUVFHRXdKVlV6QWVGdzB4TnpBeU1qSXlNakl6TWpKYUZ3MHpNakF5TVRnd01EQXdNREJhTUhJeEpqQWtCZ05WQkFNTUhVRndjR3hsSUZONWMzUmxiU0JKYm5SbFozSmhkR2x2YmlCRFFTQTBNU1l3SkFZRFZRUUxEQjFCY0hCc1pTQkRaWEowYVdacFkyRjBhVzl1SUVGMWRHaHZjbWwwZVRFVE1CRUdBMVVFQ2d3S1FYQndiR1VnU1c1akxqRUxNQWtHQTFVRUJoTUNWVk13V1RBVEJnY3Foa2pPUFFJQkJnZ3Foa2pPUFFNQkJ3TkNBQVFHYTZSV2IzMmZKOUhPTm82U0cxYk5WRFprU3NtVWFKbjZ5U0IrNHZWWUQ5emlhdXNaUnk4dTd6dWtBYlFCRTBSOFdpYXRvSndwSllybDVnWnZUM3hhbzRIM01JSDBNQThHQTFVZEV3RUIvd1FGTUFNQkFmOHdId1lEVlIwakJCZ3dGb0FVdTdEZW9WZ3ppSnFraXBuZXZyM3JyOXJMSktzd1JnWUlLd1lCQlFVSEFRRUVPakE0TURZR0NDc0dBUVVGQnpBQmhpcG9kSFJ3T2k4dmIyTnpjQzVoY0hCc1pTNWpiMjB2YjJOemNEQXpMV0Z3Y0d4bGNtOXZkR05oWnpNd053WURWUjBmQkRBd0xqQXNvQ3FnS0lZbWFIUjBjRG92TDJOeWJDNWhjSEJzWlM1amIyMHZZWEJ3YkdWeWIyOTBZMkZuTXk1amNtd3dIUVlEVlIwT0JCWUVGSHBIdWppS0ZTUklJa2JOdm84YUpIczBBeXBwTUE0R0ExVWREd0VCL3dRRUF3SUJCakFRQmdvcWhraUc5Mk5rQmdJUkJBSUZBREFLQmdncWhrak9QUVFEQXdObkFEQmtBakFWREttT3hxK1dhV3VubjkxYzFBTlpiSzVTMUdER2kzYmd0OFdpOFFsODRKcmphN0hqZkRIRUozcW5qb245cTNjQ01HRXpJUEVwLy9tSE1xNHB5R1E5ZG50UnBOSUNMM2ErWUNLUjhkVTZkZHkwNHNZcWx2N0dDZHhLVDlVazhQektzakNDQXdjd2dnS3RvQU1DQVFJQ0NCZUFxRFJtWk9yTE1Bb0dDQ3FHU000OUJBTUNNSEl4SmpBa0JnTlZCQU1NSFVGd2NHeGxJRk41YzNSbGJTQkpiblJsWjNKaGRHbHZiaUJEUVNBME1TWXdKQVlEVlFRTERCMUJjSEJzWlNCRFpYSjBhV1pwWTJGMGFXOXVJRUYxZEdodmNtbDBlVEVUTUJFR0ExVUVDZ3dLUVhCd2JHVWdTVzVqTGpFTE1Ba0dBMVVFQmhNQ1ZWTXdIaGNOTWpReE1USXdNRE15TURRMVdoY05Namd4TWpFME1UZ3dNRE13V2pCT01Tb3dLQVlEVlFRRERDRlhWMFJTSUZCeWIzWnBjMmx2Ym1sdVp5QlFjbTltYVd4bElGTnBaMjVwYm1jeEV6QVJCZ05WQkFvTUNrRndjR3hsSUVsdVl5NHhDekFKQmdOVkJBWVRBbFZUTUZrd0V3WUhLb1pJemowQ0FRWUlLb1pJemowREFRY0RRZ0FFOVpSYkRzSFVWRDhaM2taZ0pWOCtqaW5QYkUrVDFSeTNmYlpTWHgvaCsyTUJzb0licUtGT3ROUHQ3MWE1RjlTWXpXQzN6azZwdHpwWGRsUG9NME92ZmFPQ0FVOHdnZ0ZMTUF3R0ExVWRFd0VCL3dRQ01BQXdId1lEVlIwakJCZ3dGb0FVZWtlNk9Jb1ZKRWdpUnMyK2p4b2tlelFES21rd1FRWUlLd1lCQlFVSEFRRUVOVEF6TURFR0NDc0dBUVVGQnpBQmhpVm9kSFJ3T2k4dmIyTnpjQzVoY0hCc1pTNWpiMjB2YjJOemNEQXpMV0Z6YVdOaE5EQXpNSUdXQmdOVkhTQUVnWTR3Z1lzd2dZZ0dDU3FHU0liM1kyUUZBVEI3TUhrR0NDc0dBUVVGQndJQ01HME1hMVJvYVhNZ1kyVnlkR2xtYVdOaGRHVWdhWE1nZEc4Z1ltVWdkWE5sWkNCbGVHTnNkWE5wZG1Wc2VTQm1iM0lnWm5WdVkzUnBiMjV6SUdsdWRHVnlibUZzSUhSdklFRndjR3hsSUZCeWIyUjFZM1J6SUdGdVpDOXZjaUJCY0hCc1pTQndjbTlqWlhOelpYTXVNQjBHQTFVZERnUVdCQlRwVXM0TnNNYUlHbVZLdUpzUmovSGNIa2NVZkRBT0JnTlZIUThCQWY4RUJBTUNCNEF3RHdZSktvWklodmRqWkF3VEJBSUZBREFLQmdncWhrak9QUVFEQWdOSUFEQkZBaUVBNzVxOFhhQmFabXhrdWMwM2s2bFR0RGZGeDJ6aGcvb1hHdDRMUGRhcmFHWUNJSGdqMmNPSUIwazk5ZnVKajVCdVdPQlhuMlAvV09IV2oxeVlZOVk2UGorZE1ZSUIxakNDQWRJQ0FRRXdmakJ5TVNZd0pBWURWUVFEREIxQmNIQnNaU0JUZVhOMFpXMGdTVzUwWldkeVlYUnBiMjRnUTBFZ05ERW1NQ1FHQTFVRUN3d2RRWEJ3YkdVZ1EyVnlkR2xtYVdOaGRHbHZiaUJCZFhSb2IzSnBkSGt4RXpBUkJnTlZCQW9NQ2tGd2NHeGxJRWx1WXk0eEN6QUpCZ05WQkFZVEFsVlRBZ2dYZ0tnMFptVHF5ekFOQmdsZ2hrZ0JaUU1FQWdFRkFLQ0I2VEFZQmdrcWhraUc5dzBCQ1FNeEN3WUpLb1pJaHZjTkFRY0JNQndHQ1NxR1NJYjNEUUVKQlRFUEZ3MHlOVEEyTVRBd09ETXpNRFJhTUNvR0NTcUdTSWIzRFFFSk5ERWRNQnN3RFFZSllJWklBV1VEQkFJQkJRQ2hDZ1lJS29aSXpqMEVBd0l3THdZSktvWklodmNOQVFrRU1TSUVJUEdmeE5DVVJMakIyYlRkYStqcThpL2x4UllFYWQvbHFBbDV0d1ZMb2hrVE1GSUdDU3FHU0liM0RRRUpEekZGTUVNd0NnWUlLb1pJaHZjTkF3Y3dEZ1lJS29aSWh2Y05Bd0lDQWdDQU1BMEdDQ3FHU0liM0RRTUNBZ0ZBTUFjR0JTc09Bd0lITUEwR0NDcUdTSWIzRFFNQ0FnRW9NQW9HQ0NxR1NNNDlCQU1DQkVZd1JBSWdZMi95dnpzbzg1NklTNnptQ0N5Q3J6ZSt3OWtTeHBZR0l5Yk1pcHNaSzJjQ0lHSU5OMUgwaWxSUzhTRzNIWUhsSXg5ZUsvRGR1OUREaHlMb2ZuK1lsRS84PC9kYXRhPgoJCQkJCQkJCQoJPGtleT5FbnRpdGxlbWVudHM8L2tleT4KCTxkaWN0PgoJCTxrZXk+YmV0YS1yZXBvcnRzLWFjdGl2ZTwva2V5PgoJCTx0cnVlLz4KCQkJCQoJCQkJPGtleT5hcHBsaWNhdGlvbi1pZGVudGlmaWVyPC9rZXk+CgkJPHN0cmluZz5RTDc2WFlINzNQLm9yZy5yZWFjdGpzLm5hdGl2ZS5leGFtcGxlLnRlc3RhcHAudHVydGxldjI8L3N0cmluZz4KCQkJCQoJCQkJPGtleT5rZXljaGFpbi1hY2Nlc3MtZ3JvdXBzPC9rZXk+CgkJPGFycmF5PgoJCQkJPHN0cmluZz5RTDc2WFlINzNQLio8L3N0cmluZz4KCQkJCTxzdHJpbmc+Y29tLmFwcGxlLnRva2VuPC9zdHJpbmc+CgkJPC9hcnJheT4KCQkJCQoJCQkJPGtleT5nZXQtdGFzay1hbGxvdzwva2V5PgoJCTxmYWxzZS8+CgkJCQkKCQkJCTxrZXk+Y29tLmFwcGxlLmRldmVsb3Blci50ZWFtLWlkZW50aWZpZXI8L2tleT4KCQk8c3RyaW5nPlFMNzZYWUg3M1A8L3N0cmluZz4KCgk8L2RpY3Q+Cgk8a2V5PkV4cGlyYXRpb25EYXRlPC9rZXk+Cgk8ZGF0ZT4yMDI2LTA2LTEwVDA4OjIyOjQ5WjwvZGF0ZT4KCTxrZXk+TmFtZTwva2V5PgoJPHN0cmluZz4qW2V4cG9dIG9yZy5yZWFjdGpzLm5hdGl2ZS5leGFtcGxlLnRlc3RhcHAudHVydGxldjIgQXBwU3RvcmUgMjAyNC0wNi0wNFQxODoxMToyNi42MDJaPC9zdHJpbmc+Cgk8a2V5PlRlYW1JZGVudGlmaWVyPC9rZXk+Cgk8YXJyYXk+CgkJPHN0cmluZz5RTDc2WFlINzNQPC9zdHJpbmc+Cgk8L2FycmF5PgoJPGtleT5UZWFtTmFtZTwva2V5PgoJPHN0cmluZz5BbGljamEgV2FyY2hhxYI8L3N0cmluZz4KCTxrZXk+VGltZVRvTGl2ZTwva2V5PgoJPGludGVnZXI+MzY0PC9pbnRlZ2VyPgoJPGtleT5VVUlEPC9rZXk+Cgk8c3RyaW5nPmRjYmJjYTAwLTliZWMtNDRkNC04M2RlLTJiYWMzYTQ2ZWE0Yjwvc3RyaW5nPgoJPGtleT5WZXJzaW9uPC9rZXk+Cgk8aW50ZWdlcj4xPC9pbnRlZ2VyPgo8L2RpY3Q+CjwvcGxpc3Q+oIINPzCCBDQwggMcoAMCAQICCD1Z+Dfq0difMA0GCSqGSIb3DQEBCwUAMHMxLTArBgNVBAMMJEFwcGxlIGlQaG9uZSBDZXJ0aWZpY2F0aW9uIEF1dGhvcml0eTEgMB4GA1UECwwXQ2VydGlmaWNhdGlvbiBBdXRob3JpdHkxEzARBgNVBAoMCkFwcGxlIEluYy4xCzAJBgNVBAYTAlVTMB4XDTI0MTIxNjE5MjEwMVoXDTI5MTIxMTE4MTM1OVowWTE1MDMGA1UEAwwsQXBwbGUgaVBob25lIE9TIFByb3Zpc2lvbmluZyBQcm9maWxlIFNpZ25pbmcxEzARBgNVBAoMCkFwcGxlIEluYy4xCzAJBgNVBAYTAlVTMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA0JMxq/hTHt+tHi9k98lN15a+X0s9eWRcQ+4pw0DS+i8coLa8EXPv8CKw3975+c5V4/VCAbUjZUESO/d8Rn2JE0WVROyWlWJml5ADANngrsXZsZwgLZbghe1va9NS04M81AEjJenlxtQR2HmqCMLdFVj174qTSw1L7g22h5N1ERBVywx4B9s3cEY7l/rE63gp4PTseONh2kBXgAe7iJylx0ltyCbTlR9NIaKNaHODHuZZWoWWVVSwlS4l3HcNYYeBjYmkA3s8AHcsiWpZ02RE2XSlW4qxUmFfbgQw1PPiEDQMlYxL4XvWK4WD8ic5jXAII+IUiP+T9C1tTCbiizeGLQIDAQABo4HlMIHiMAwGA1UdEwEB/wQCMAAwHwYDVR0jBBgwFoAUb/GVGGJc4Mjxxe1sGMng02RSmCAwQAYIKwYBBQUHAQEENDAyMDAGCCsGAQUFBzABhiRodHRwOi8vb2NzcC5hcHBsZS5jb20vb2NzcDAzLWFpcGNhMDcwLwYDVR0fBCgwJjAkoCKgIIYeaHR0cDovL2NybC5hcHBsZS5jb20vYWlwY2EuY3JsMB0GA1UdDgQWBBS8tcXpvfzL0J7clLAe+CGUXP8JLjAOBgNVHQ8BAf8EBAMCB4AwDwYJKoZIhvdjZAY6BAIFADANBgkqhkiG9w0BAQsFAAOCAQEAMjTC6XeqX+DGyqLzd1nnime48VHz+7D2l7+xEaPhoTy7I5LpEpOicR288Zpxb6Q8bzaStuBHgqKT4+e4j7vfJURiAs/NvPf7jYoJcHhlwhlNJctyYiHkqWj5EJoueg8ovqYDBtFVYR+vfPiU1HEO4tMlVIvOrdVoB1u9LXvHYkV5uamHTPgYO4CuEEtx2Hgr5gqvmufZTczqW7ejl1Vr7A2geMfAsM/L3BBMMJITcZTWr+DsyenJm84lMu4RDSEXJIvxlS4iYkZFf+Db1xZUwbk+09qKuWX2tLQjf3hd2XJ2ZGOnCFXH2beU2yO/85On1AREQw3K4layK5QopD1yhzCCBEQwggMsoAMCAQICCFxjyuRKN1PJMA0GCSqGSIb3DQEBCwUAMGIxCzAJBgNVBAYTAlVTMRMwEQYDVQQKEwpBcHBsZSBJbmMuMSYwJAYDVQQLEx1BcHBsZSBDZXJ0aWZpY2F0aW9uIEF1dGhvcml0eTEWMBQGA1UEAxMNQXBwbGUgUm9vdCBDQTAeFw0xNzA1MTAyMTI3MzBaFw0zMDEyMzEwMDAwMDBaMHMxLTArBgNVBAMMJEFwcGxlIGlQaG9uZSBDZXJ0aWZpY2F0aW9uIEF1dGhvcml0eTEgMB4GA1UECwwXQ2VydGlmaWNhdGlvbiBBdXRob3JpdHkxEzARBgNVBAoMCkFwcGxlIEluYy4xCzAJBgNVBAYTAlVTMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAyUVqAQ8+gwSGx/y/3F7wHoHuFzBzYyYu3j16JM2TPk85R7p1vvPA0vFZoqsf/gqGPNktmgfyDmu5KZEaXyIKi/FyWAWuTEtExXmngDywiOCMDCeEXRnlhxk2y+PFdrew9EFyUfQFXINLom2mUbjxJt97Xq1lDMaymFGMu30bTMFOyAjH0u1kC7TdG41PQH0bj0iWklvz0Jh+2bykGQ6ZYbtBXQHMW3d6fSTQ3NNT/8PcxZQstlpNjhgjOb3ZxlI+0fL0JYqhKof92AxGKVH/7RdsiSVrh7+KaRSfd5/DFbdos4hFvYTmBgJBZA+tKii4FcngrKeKunIENLJ4jPiyhQIDAQABo4HsMIHpMA8GA1UdEwEB/wQFMAMBAf8wHwYDVR0jBBgwFoAUK9BpR5R2Cf70a40uQKb3R01/CF4wRAYIKwYBBQUHAQEEODA2MDQGCCsGAQUFBzABhihodHRwOi8vb2NzcC5hcHBsZS5jb20vb2NzcDAzLWFwcGxlcm9vdGNhMC4GA1UdHwQnMCUwI6AhoB+GHWh0dHA6Ly9jcmwuYXBwbGUuY29tL3Jvb3QuY3JsMB0GA1UdDgQWBBRv8ZUYYlzgyPHF7WwYyeDTZFKYIDAOBgNVHQ8BAf8EBAMCAQYwEAYKKoZIhvdjZAYCEgQCBQAwDQYJKoZIhvcNAQELBQADggEBADrPrJiNvpIgIQmtlfOxXCH6Ni1XIER0c2SSCLOWrPdtl/pbNDgnzxJG0zwR8AfJmZCx0egRCaXjpWtsYwg/niX61ZmcTOblzo6yTWjsi6ujok+KERU+3BQrHMZEtm9nxVtPlSkth1w/3IMed0/t2lSnLecTgcFjxFQLG0sKaigiCNQ3knx/Zyhfrz0/t6xZHTg0ZFruM0oZQkQpxMoYa+HBUy0t9E3CFfYzMhh48SZvik3rlEyj6P8PswOLZdrrLthlUJ/cn4rfMaiEVNxSUkHSshMdMUZHiF8+7sPyjCMEleusij6CbAafLuOLQ5piWzQN9JnPLO66coYZI6X8jrUwggS7MIIDo6ADAgECAgECMA0GCSqGSIb3DQEBBQUAMGIxCzAJBgNVBAYTAlVTMRMwEQYDVQQKEwpBcHBsZSBJbmMuMSYwJAYDVQQLEx1BcHBsZSBDZXJ0aWZpY2F0aW9uIEF1dGhvcml0eTEWMBQGA1UEAxMNQXBwbGUgUm9vdCBDQTAeFw0wNjA0MjUyMTQwMzZaFw0zNTAyMDkyMTQwMzZaMGIxCzAJBgNVBAYTAlVTMRMwEQYDVQQKEwpBcHBsZSBJbmMuMSYwJAYDVQQLEx1BcHBsZSBDZXJ0aWZpY2F0aW9uIEF1dGhvcml0eTEWMBQGA1UEAxMNQXBwbGUgUm9vdCBDQTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAOSRqQkfkdseR1DrBe1eeYQt6zaiV0xV7IsZid75S2z1B6siMALoGD74UAnTf0GomPnRymacJGsR0KO75Bsqwx+VnnoMpEeLW9QWNzPLxA9NzhRp0ckZcvVdDtV/X5vyJQO6VY9NXQ3xZDUjFUsVWR2zlPf2nJ7PULrBWFBnjwi0IPfLrCwgb3C2PwEwjLdDzw+dPfMrSSgayP7OtbkO2V4c1ss9tTqt9A8OAJILsSEWLnTVPA3bYharo3GSR1NVwa8vQbP4++NwzeajTEV+H0xrUJZBicR0YgsQg0GHM4qBsTBY7FoEMoxos48d3mVz/2deZbxJ2HafMxRloXeUyS0CAwEAAaOCAXowggF2MA4GA1UdDwEB/wQEAwIBBjAPBgNVHRMBAf8EBTADAQH/MB0GA1UdDgQWBBQr0GlHlHYJ/vRrjS5ApvdHTX8IXjAfBgNVHSMEGDAWgBQr0GlHlHYJ/vRrjS5ApvdHTX8IXjCCAREGA1UdIASCAQgwggEEMIIBAAYJKoZIhvdjZAUBMIHyMCoGCCsGAQUFBwIBFh5odHRwczovL3d3dy5hcHBsZS5jb20vYXBwbGVjYS8wgcMGCCsGAQUFBwICMIG2GoGzUmVsaWFuY2Ugb24gdGhpcyBjZXJ0aWZpY2F0ZSBieSBhbnkgcGFydHkgYXNzdW1lcyBhY2NlcHRhbmNlIG9mIHRoZSB0aGVuIGFwcGxpY2FibGUgc3RhbmRhcmQgdGVybXMgYW5kIGNvbmRpdGlvbnMgb2YgdXNlLCBjZXJ0aWZpY2F0ZSBwb2xpY3kgYW5kIGNlcnRpZmljYXRpb24gcHJhY3RpY2Ugc3RhdGVtZW50cy4wDQYJKoZIhvcNAQEFBQADggEBAFw2mUwteLftjJvc83eb8nbSdzBPwR+Fg4UbmT1HN/Kpm0COLNSxkBLYvvRzm+7SZA/LeU802KI++Xj/a8gH7H05g4tTINM4xLG/mk8Ka/8r/FmnBQl8F0BWER5007eLIztHo9VvJOLr0bdw3w9F4SfK8W147ee1Fxeo3H4iNcol1dkP1mvUoiQjEfehrI9zgWDGG1sJL5Ky+ERI8GA4nhX1PSZnIIozavcNgs/e66Mv+VNqW2TAYzN39zoHLFbr2g8hDtq6cxlPtdk2f8GHVdmnmbkyQvvY1XGefqFStxu9k0IkEirHDx22TZxeY8hLgBdQqorV2uT80AkHN7B1dSExggKFMIICgQIBATB/MHMxLTArBgNVBAMMJEFwcGxlIGlQaG9uZSBDZXJ0aWZpY2F0aW9uIEF1dGhvcml0eTEgMB4GA1UECwwXQ2VydGlmaWNhdGlvbiBBdXRob3JpdHkxEzARBgNVBAoMCkFwcGxlIEluYy4xCzAJBgNVBAYTAlVTAgg9Wfg36tHYnzAJBgUrDgMCGgUAoIHcMBgGCSqGSIb3DQEJAzELBgkqhkiG9w0BBwEwHAYJKoZIhvcNAQkFMQ8XDTI1MDYxMDA4MzMwNFowIwYJKoZIhvcNAQkEMRYEFJxXrTwrpTx16wHCr42McagHLN5aMCkGCSqGSIb3DQEJNDEcMBowCQYFKw4DAhoFAKENBgkqhkiG9w0BAQEFADBSBgkqhkiG9w0BCQ8xRTBDMAoGCCqGSIb3DQMHMA4GCCqGSIb3DQMCAgIAgDANBggqhkiG9w0DAgIBQDAHBgUrDgMCBzANBggqhkiG9w0DAgIBKDANBgkqhkiG9w0BAQEFAASCAQAFR1Ds7kzMubi6IHqjGGbixW5uNNrrCW0bpiYJy2PyfTTzALY2hLnwCuqryHjINbc6UNlaCl5Cy+G3aYlFVVz8HH+UHj3BHf8VDhTuylolMYpaDeKmcLuOPEzDBoxBwEeVNtC1VMM0Qhca0Gn+0oSQpTh+Xin4RMLttVw6Iy7RUE627lJ5pYNw63lMXF8MFqdjKDz/le2nmTGt+wox9WPsVpt8W2ezORm2lgARvep61rbOoCiCI92NjLLC7LnQR9eWvK5BgHueP0DbAuPq26/8kkIt4YzLPBerEyFfOhcY84fiI08xgcRjhbqC00ZNmE3WEw8YVV75YL0o1CFdp5Ga`, + certFingerprint: '61F85A5106372ABAD1E69EF2C115712CC1C1702C', +}; diff --git a/packages/build-tools/src/ios/credentials/__tests__/provisioningProfile.test.ios.ts b/packages/build-tools/src/ios/credentials/__tests__/provisioningProfile.test.ios.ts new file mode 100644 index 0000000000..7f2b6a0862 --- /dev/null +++ b/packages/build-tools/src/ios/credentials/__tests__/provisioningProfile.test.ios.ts @@ -0,0 +1,78 @@ +import { Ios } from '@expo/eas-build-job'; +import { createLogger } from '@expo/logger'; + +import { BuildContext } from '../../../context'; +import Keychain from '../keychain'; +import ProvisioningProfile from '../provisioningProfile'; + +import { provisioningProfile } from './fixtures'; + +const mockLogger = createLogger({ name: 'mock-logger' }); + +jest.setTimeout(60 * 1000); + +// Those are in fact "real" tests in that they really execute the `fastlane` commands. +// We need the JS code to modify the file system, so we need to mock it. +jest.unmock('fs'); + +describe('ProvisioningProfile class', () => { + describe('verifyCertificate method', () => { + let ctx: BuildContext; + let keychain: Keychain; + + beforeAll(async () => { + ctx = new BuildContext({ projectRootDirectory: '.' } as Ios.Job, { + workingdir: '/workingdir', + logBuffer: { getLogs: () => [], getPhaseLogs: () => [] }, + logger: mockLogger, + env: { + __API_SERVER_URL: 'http://api.expo.test', + }, + uploadArtifact: jest.fn(), + }); + keychain = new Keychain(ctx); + await keychain.create(); + }); + + afterAll(async () => { + await keychain.destroy(); + }); + + it("shouldn't throw any error if the provisioning profile and distribution certificate match", async () => { + const pp = new ProvisioningProfile( + ctx, + Buffer.from(provisioningProfile.dataBase64, 'base64'), + keychain.data.path, + 'testapp', + 'Abc 123' + ); + try { + await pp.init(); + expect(() => { + pp.verifyCertificate(provisioningProfile.certFingerprint); + }).not.toThrow(); + } finally { + await pp.destroy(); + } + }); + + it("should throw an error if the provisioning profile and distribution certificate don't match", async () => { + const pp = new ProvisioningProfile( + ctx, + Buffer.from(provisioningProfile.dataBase64, 'base64'), + keychain.data.path, + 'testapp', + 'Abc 123' + ); + + try { + await pp.init(); + expect(() => { + pp.verifyCertificate('2137'); + }).toThrowError(/don't match/); + } finally { + await pp.destroy(); + } + }); + }); +}); diff --git a/packages/build-tools/src/ios/credentials/distributionCertificate.ts b/packages/build-tools/src/ios/credentials/distributionCertificate.ts new file mode 100644 index 0000000000..e49c1600c9 --- /dev/null +++ b/packages/build-tools/src/ios/credentials/distributionCertificate.ts @@ -0,0 +1,46 @@ +import { Ios } from '@expo/eas-build-job'; +import forge from 'node-forge'; + +export function getFingerprint({ dataBase64, password }: Ios.DistributionCertificate): string { + const certData = getCertData(dataBase64, password); + const certAsn1 = forge.pki.certificateToAsn1(certData); + const certDer = forge.asn1.toDer(certAsn1).getBytes(); + const fingerprint = forge.md.sha1.create().update(certDer).digest().toHex().toUpperCase(); + return fingerprint; +} + +export function getCommonName({ dataBase64, password }: Ios.DistributionCertificate): string { + const certData = getCertData(dataBase64, password); + const { attributes } = certData.subject; + const commonNameAttribute = attributes.find( + ({ name }: { name?: string }) => name === 'commonName' + ); + return Buffer.from(commonNameAttribute.value, 'ascii').toString(); +} + +function getCertData(certificateBase64: string, password: string): any { + const p12Der = forge.util.decode64(certificateBase64); + const p12Asn1 = forge.asn1.fromDer(p12Der); + let p12: forge.pkcs12.Pkcs12Pfx; + try { + if (password) { + p12 = forge.pkcs12.pkcs12FromAsn1(p12Asn1, password); + } else { + p12 = forge.pkcs12.pkcs12FromAsn1(p12Asn1); + } + } catch (_error: any) { + const error: Error = _error; + if (/Invalid password/.exec(error.message)) { + throw new Error('Provided password for the distribution certificate is probably invalid'); + } else { + throw error; + } + } + + const certBagType = forge.pki.oids.certBag; + const certData = p12.getBags({ bagType: certBagType })?.[certBagType]?.[0]?.cert; + if (!certData) { + throw new Error("getCertData: couldn't find cert bag"); + } + return certData; +} diff --git a/packages/build-tools/src/ios/credentials/keychain.ts b/packages/build-tools/src/ios/credentials/keychain.ts new file mode 100644 index 0000000000..a8a9c42ca1 --- /dev/null +++ b/packages/build-tools/src/ios/credentials/keychain.ts @@ -0,0 +1,110 @@ +import os from 'os'; +import path from 'path'; + +import { Ios } from '@expo/eas-build-job'; +import spawn from '@expo/turtle-spawn'; +import { v4 as uuid } from 'uuid'; + +import { BuildContext } from '../../context'; +import { runFastlane } from '../fastlane'; + +export default class Keychain { + private readonly keychainPath: string; + private readonly keychainPassword: string; + private created = false; + private destroyed = false; + + constructor(private readonly ctx: BuildContext) { + this.keychainPath = path.join(os.tmpdir(), `eas-build-${uuid()}.keychain`); + this.keychainPassword = uuid(); + } + + get data(): { path: string; password: string } { + return { + path: this.keychainPath, + password: this.keychainPassword, + }; + } + + public async create(): Promise { + this.ctx.logger.debug(`Creating keychain - ${this.keychainPath}`); + await runFastlane([ + 'run', + 'create_keychain', + `path:${this.keychainPath}`, + `password:${this.keychainPassword}`, + 'unlock:true', + 'timeout:360000', + ]); + this.created = true; + } + + public async importCertificate(certPath: string, certPassword: string): Promise { + if (!this.created) { + throw new Error('You must create a keychain first.'); + } + + this.ctx.logger.debug(`Importing certificate ${certPath} into keychain ${this.keychainPath}`); + await runFastlane([ + 'run', + 'import_certificate', + `certificate_path:${certPath}`, + `certificate_password:${certPassword}`, + `keychain_path:${this.keychainPath}`, + `keychain_password:${this.keychainPassword}`, + ]); + } + + public async ensureCertificateImported(teamId: string, fingerprint: string): Promise { + const identities = await this.findIdentitiesByTeamId(teamId); + if (!identities.includes(fingerprint)) { + throw new Error( + `Distribution certificate with fingerprint ${fingerprint} hasn't been imported successfully` + ); + } + } + + public async destroy(keychainPath?: string): Promise { + if (!keychainPath && !this.created) { + this.ctx.logger.warn("There is nothing to destroy, a keychain hasn't been created yet."); + return; + } + if (this.destroyed) { + this.ctx.logger.warn('The keychain has been already destroyed'); + return; + } + const keychainToDeletePath = keychainPath ?? this.keychainPath; + this.ctx.logger.info(`Destroying keychain - ${keychainToDeletePath}`); + try { + await runFastlane(['run', 'delete_keychain', `keychain_path:${keychainToDeletePath}`]); + this.destroyed = true; + } catch (err) { + this.ctx.logger.error({ err }, 'Failed to delete the keychain\n'); + throw err; + } + } + + public async cleanUpKeychains(): Promise { + const { stdout } = await spawn('security', ['list-keychains'], { stdio: 'pipe' }); + const keychainList = (/"(.*)"/g.exec(stdout) ?? ([] as string[])).map((i) => + i.slice(1, i.length - 1) + ); + const turtleKeychainList = keychainList.filter((keychain) => + /eas-build-[\w-]+\.keychain$/.exec(keychain) + ); + for (const turtleKeychainPath of turtleKeychainList) { + await this.destroy(turtleKeychainPath); + } + } + + private async findIdentitiesByTeamId(teamId: string): Promise { + const { output } = await spawn( + 'security', + ['find-identity', '-v', '-s', `(${teamId})`, this.keychainPath], + { + stdio: 'pipe', + } + ); + return output.join(''); + } +} diff --git a/packages/build-tools/src/ios/credentials/manager.ts b/packages/build-tools/src/ios/credentials/manager.ts new file mode 100644 index 0000000000..5fc62fb7f9 --- /dev/null +++ b/packages/build-tools/src/ios/credentials/manager.ts @@ -0,0 +1,165 @@ +import assert from 'assert'; +import os from 'os'; +import path from 'path'; + +import { Ios } from '@expo/eas-build-job'; +import fs from 'fs-extra'; +import { orderBy } from 'lodash'; +import nullthrows from 'nullthrows'; +import { v4 as uuid } from 'uuid'; + +import { BuildContext } from '../../context'; + +import * as distributionCertificateUtils from './distributionCertificate'; +import Keychain from './keychain'; +import ProvisioningProfile, { + DistributionType, + ProvisioningProfileData, +} from './provisioningProfile'; + +export interface Credentials { + applicationTargetProvisioningProfile: ProvisioningProfile; + keychainPath: string; + targetProvisioningProfiles: TargetProvisioningProfiles; + distributionType: DistributionType; + teamId: string; +} + +export type TargetProvisioningProfiles = Record; + +export default class IosCredentialsManager { + private keychain?: Keychain; + private readonly provisioningProfiles: ProvisioningProfile[] = []; + private cleanedUp = false; + + constructor(private readonly ctx: BuildContext) {} + + public async prepare(): Promise { + if (this.ctx.job.simulator) { + return null; + } + + const { buildCredentials } = nullthrows( + this.ctx.job.secrets, + 'Secrets must be defined for non-custom builds' + ); + if (!buildCredentials) { + throw new Error('credentials are required for an iOS build'); + } + + this.ctx.logger.info('Preparing credentials'); + + this.ctx.logger.info('Creating keychain'); + this.keychain = new Keychain(this.ctx); + await this.keychain.create(); + + const targets = Object.keys(buildCredentials); + const targetProvisioningProfiles: TargetProvisioningProfiles = {}; + for (const target of targets) { + const provisioningProfile = await this.prepareTargetCredentials( + target, + buildCredentials[target] + ); + this.provisioningProfiles.push(provisioningProfile); + targetProvisioningProfiles[target] = provisioningProfile.data; + } + + const applicationTargetProvisioningProfile = this.getApplicationTargetProvisioningProfile(); + + // TODO: ensure that all dist types and team ids in the array are the same + const { distributionType, teamId } = applicationTargetProvisioningProfile.data; + + return { + applicationTargetProvisioningProfile, + keychainPath: this.keychain.data.path, + targetProvisioningProfiles, + distributionType, + teamId, + }; + } + + public async cleanUp(): Promise { + if (this.cleanedUp || (!this.keychain && this.provisioningProfiles.length === 0)) { + return; + } + + if (this.keychain) { + await this.keychain.destroy(); + } + if (this.provisioningProfiles) { + for (const provisioningProfile of this.provisioningProfiles) { + await provisioningProfile.destroy(); + } + } + this.cleanedUp = true; + } + + private async prepareTargetCredentials( + target: string, + targetCredentials: Ios.TargetCredentials + ): Promise> { + try { + assert(this.keychain, 'Keychain should be initialized'); + + this.ctx.logger.info(`Preparing credentials for target '${target}'`); + const distCertPath = path.join(os.tmpdir(), `${uuid()}.p12`); + + this.ctx.logger.info('Getting distribution certificate fingerprint and common name'); + const certificateFingerprint = distributionCertificateUtils.getFingerprint( + targetCredentials.distributionCertificate + ); + const certificateCommonName = distributionCertificateUtils.getCommonName( + targetCredentials.distributionCertificate + ); + this.ctx.logger.info( + `Fingerprint = "${certificateFingerprint}", common name = ${certificateCommonName}` + ); + + this.ctx.logger.info(`Writing distribution certificate to ${distCertPath}`); + await fs.writeFile( + distCertPath, + new Uint8Array(Buffer.from(targetCredentials.distributionCertificate.dataBase64, 'base64')) + ); + + this.ctx.logger.info('Importing distribution certificate into the keychain'); + await this.keychain.importCertificate( + distCertPath, + targetCredentials.distributionCertificate.password + ); + + this.ctx.logger.info('Initializing provisioning profile'); + const provisioningProfile = new ProvisioningProfile( + this.ctx, + Buffer.from(targetCredentials.provisioningProfileBase64, 'base64'), + this.keychain.data.path, + target, + certificateCommonName + ); + await provisioningProfile.init(); + + this.ctx.logger.info( + 'Validating whether the distribution certificate has been imported successfully' + ); + await this.keychain.ensureCertificateImported( + provisioningProfile.data.teamId, + certificateFingerprint + ); + + this.ctx.logger.info( + 'Verifying whether the distribution certificate and provisioning profile match' + ); + provisioningProfile.verifyCertificate(certificateFingerprint); + + return provisioningProfile; + } catch (err) { + await this.cleanUp(); + throw err; + } + } + + private getApplicationTargetProvisioningProfile(): ProvisioningProfile { + // sorting works because bundle ids share common prefix + const sorted = orderBy(this.provisioningProfiles, 'data.bundleIdentifier', 'asc'); + return sorted[0]; + } +} diff --git a/packages/build-tools/src/ios/credentials/provisioningProfile.ts b/packages/build-tools/src/ios/credentials/provisioningProfile.ts new file mode 100644 index 0000000000..b961f10620 --- /dev/null +++ b/packages/build-tools/src/ios/credentials/provisioningProfile.ts @@ -0,0 +1,148 @@ +import crypto from 'crypto'; +import os from 'os'; +import path from 'path'; + +import { errors, Ios } from '@expo/eas-build-job'; +import spawn from '@expo/turtle-spawn'; +import fs from 'fs-extra'; +import plist from 'plist'; +import { v4 as uuid } from 'uuid'; + +import { BuildContext } from '../../context'; + +export interface ProvisioningProfileData { + path: string; + target: string; + bundleIdentifier: string; + teamId: string; + uuid: string; + name: string; + developerCertificate: Buffer; + certificateCommonName: string; + distributionType: DistributionType; +} + +export enum DistributionType { + AD_HOC = 'ad-hoc', + APP_STORE = 'app-store', + ENTERPRISE = 'enterprise', +} + +const PROVISIONING_PROFILES_DIRECTORY = path.join( + os.homedir(), + 'Library/MobileDevice/Provisioning Profiles' +); + +export default class ProvisioningProfile { + get data(): ProvisioningProfileData { + if (!this.profileData) { + throw new Error('You must init the profile first!'); + } else { + return this.profileData; + } + } + + private readonly profilePath: string; + private profileData?: ProvisioningProfileData; + + constructor( + private readonly ctx: BuildContext, + private readonly profile: Buffer, + private readonly keychainPath: string, + private readonly target: string, + private readonly certificateCommonName: string + ) { + this.profilePath = path.join(PROVISIONING_PROFILES_DIRECTORY, `${uuid()}.mobileprovision`); + } + + public async init(): Promise { + this.ctx.logger.debug(`Making sure ${PROVISIONING_PROFILES_DIRECTORY} exits`); + await fs.ensureDir(PROVISIONING_PROFILES_DIRECTORY); + + this.ctx.logger.debug(`Writing provisioning profile to ${this.profilePath}`); + await fs.writeFile(this.profilePath, new Uint8Array(this.profile)); + + this.ctx.logger.debug('Loading provisioning profile'); + await this.load(); + } + + public async destroy(): Promise { + if (!this.profilePath) { + this.ctx.logger.warn( + "There is nothing to destroy, a provisioning profile hasn't been created yet." + ); + return; + } + this.ctx.logger.info('Removing provisioning profile'); + await fs.remove(this.profilePath); + } + + public verifyCertificate(fingerprint: string): void { + const devCertFingerprint = this.genDerCertFingerprint(); + if (devCertFingerprint !== fingerprint) { + throw new errors.CredentialsDistCertMismatchError( + `Provisioning profile and distribution certificate don't match. +Profile's certificate fingerprint = ${devCertFingerprint}, distribution certificate fingerprint = ${fingerprint}` + ); + } + } + + private async load(): Promise { + let result; + try { + result = await spawn( + 'security', + ['cms', '-D', '-k', this.keychainPath, '-i', this.profilePath], + { + stdio: 'pipe', + } + ); + } catch (err: any) { + throw new Error(err.stderr.trim()); + } + const { output } = result; + + const plistRaw = output.join(''); + let plistData; + try { + plistData = plist.parse(plistRaw) as plist.PlistObject; + } catch (error: any) { + throw new Error(`Error when parsing plist: ${error.message}`); + } + + const applicationIdentifier = (plistData.Entitlements as plist.PlistObject)[ + 'application-identifier' + ] as string; + const bundleIdentifier = applicationIdentifier.replace(/^.+?\./, ''); + + this.profileData = { + path: this.profilePath, + target: this.target, + bundleIdentifier, + teamId: (plistData.TeamIdentifier as string[])[0], + uuid: plistData.UUID as string, + name: plistData.Name as string, + developerCertificate: Buffer.from((plistData.DeveloperCertificates as string[])[0], 'base64'), + certificateCommonName: this.certificateCommonName, + distributionType: this.resolveDistributionType(plistData), + }; + } + + private resolveDistributionType(plistData: plist.PlistObject): DistributionType { + if (plistData.ProvisionsAllDevices) { + return DistributionType.ENTERPRISE; + } else if (plistData.ProvisionedDevices) { + return DistributionType.AD_HOC; + } else { + return DistributionType.APP_STORE; + } + } + + private genDerCertFingerprint(): string { + return crypto + .createHash('sha1') + .update(new Uint8Array(this.data.developerCertificate)) + .digest('hex') + .toUpperCase(); + } +} diff --git a/packages/build-tools/src/ios/expoUpdates.ts b/packages/build-tools/src/ios/expoUpdates.ts new file mode 100644 index 0000000000..bf501a63ff --- /dev/null +++ b/packages/build-tools/src/ios/expoUpdates.ts @@ -0,0 +1,92 @@ +import assert from 'assert'; + +import { IOSConfig } from '@expo/config-plugins'; +import fs from 'fs-extra'; +import plist from '@expo/plist'; +import { BuildJob, Job } from '@expo/eas-build-job'; + +import { BuildContext } from '../context'; + +export enum IosMetadataName { + UPDATES_CONFIGURATION_REQUEST_HEADERS_KEY = 'EXUpdatesRequestHeaders', + RUNTIME_VERSION = 'EXUpdatesRuntimeVersion', +} + +export async function iosSetRuntimeVersionNativelyAsync( + ctx: BuildContext, + runtimeVersion: string +): Promise { + const expoPlistPath = IOSConfig.Paths.getExpoPlistPath(ctx.getReactNativeProjectDirectory()); + + if (!(await fs.pathExists(expoPlistPath))) { + throw new Error(`${expoPlistPath} does not exist`); + } + + const expoPlistContents = await fs.readFile(expoPlistPath, 'utf8'); + const items = plist.parse(expoPlistContents); + items[IosMetadataName.RUNTIME_VERSION] = runtimeVersion; + const updatedExpoPlistContents = plist.build(items); + + await fs.writeFile(expoPlistPath, updatedExpoPlistContents); +} + +export async function iosSetChannelNativelyAsync(ctx: BuildContext): Promise { + assert(ctx.job.updates?.channel, 'updates.channel must be defined'); + + const expoPlistPath = IOSConfig.Paths.getExpoPlistPath(ctx.getReactNativeProjectDirectory()); + + if (!(await fs.pathExists(expoPlistPath))) { + throw new Error(`${expoPlistPath} does not exist`); + } + + const expoPlistContents = await fs.readFile(expoPlistPath, 'utf8'); + const items: Record> = plist.parse(expoPlistContents); + items[IosMetadataName.UPDATES_CONFIGURATION_REQUEST_HEADERS_KEY] = { + ...((items[IosMetadataName.UPDATES_CONFIGURATION_REQUEST_HEADERS_KEY] as Record< + string, + string + >) ?? {}), + 'expo-channel-name': ctx.job.updates.channel, + }; + const updatedExpoPlistContents = plist.build(items); + + await fs.writeFile(expoPlistPath, updatedExpoPlistContents); +} + +export async function iosGetNativelyDefinedChannelAsync( + ctx: BuildContext +): Promise { + const expoPlistPath = IOSConfig.Paths.getExpoPlistPath(ctx.getReactNativeProjectDirectory()); + + if (!(await fs.pathExists(expoPlistPath))) { + return null; + } + + const expoPlistContents = await fs.readFile(expoPlistPath, 'utf8'); + try { + const items: Record> = plist.parse(expoPlistContents); + const updatesRequestHeaders = (items[ + IosMetadataName.UPDATES_CONFIGURATION_REQUEST_HEADERS_KEY + ] ?? {}) as Record; + return updatesRequestHeaders['expo-channel-name'] ?? null; + } catch (err: any) { + throw new Error( + `Failed to parse ${IosMetadataName.UPDATES_CONFIGURATION_REQUEST_HEADERS_KEY} from Expo.plist: ${err.message}` + ); + } +} + +export async function iosGetNativelyDefinedRuntimeVersionAsync( + ctx: BuildContext +): Promise { + const expoPlistPath = IOSConfig.Paths.getExpoPlistPath(ctx.getReactNativeProjectDirectory()); + if (!(await fs.pathExists(expoPlistPath))) { + return null; + } + const expoPlistContents = await fs.readFile(expoPlistPath, 'utf8'); + const parsedPlist = plist.parse(expoPlistContents); + if (!parsedPlist) { + return null; + } + return parsedPlist[IosMetadataName.RUNTIME_VERSION] ?? null; +} diff --git a/packages/build-tools/src/ios/fastfile.ts b/packages/build-tools/src/ios/fastfile.ts new file mode 100644 index 0000000000..6ab372cd05 --- /dev/null +++ b/packages/build-tools/src/ios/fastfile.ts @@ -0,0 +1,43 @@ +import { templateString } from '@expo/template-file'; +import fs from 'fs-extra'; + +import { FastfileResignTemplate } from '../templates/FastfileResign'; + +import { TargetProvisioningProfiles } from './credentials/manager'; + +export async function createFastfileForResigningBuild({ + outputFile, + ipaPath, + signingIdentity, + keychainPath, + targetProvisioningProfiles, +}: { + outputFile: string; + ipaPath: string; + signingIdentity: string; + keychainPath: string; + targetProvisioningProfiles: TargetProvisioningProfiles; +}): Promise { + const PROFILES: { BUNDLE_ID: string; PATH: string }[] = []; + const targets = Object.keys(targetProvisioningProfiles); + for (const target of targets) { + const profile = targetProvisioningProfiles[target]; + PROFILES.push({ + BUNDLE_ID: profile.bundleIdentifier, + PATH: profile.path, + }); + } + + const output = templateString({ + input: FastfileResignTemplate, + vars: { + IPA_PATH: ipaPath, + SIGNING_IDENTITY: signingIdentity, + PROFILES, + KEYCHAIN_PATH: keychainPath, + }, + mustache: false, + }); + + await fs.writeFile(outputFile, output); +} diff --git a/packages/build-tools/src/ios/fastlane.ts b/packages/build-tools/src/ios/fastlane.ts new file mode 100644 index 0000000000..0d5e02bbf4 --- /dev/null +++ b/packages/build-tools/src/ios/fastlane.ts @@ -0,0 +1,154 @@ +import path from 'path'; + +import { Env, Ios } from '@expo/eas-build-job'; +import { bunyan } from '@expo/logger'; +import spawn, { SpawnResult } from '@expo/turtle-spawn'; +import fs from 'fs-extra'; +import nullthrows from 'nullthrows'; + +import { BuildContext, SkipNativeBuildError } from '../context'; +import { COMMON_FASTLANE_ENV } from '../common/fastlane'; + +import { createGymfileForArchiveBuild, createGymfileForSimulatorBuild } from './gymfile'; +import { Credentials } from './credentials/manager'; +import { XcodeBuildLogger } from './xcpretty'; +import { isTVOS } from './tvos'; +import { createFastfileForResigningBuild } from './fastfile'; + +export async function runFastlaneGym( + ctx: BuildContext, + { + scheme, + buildConfiguration, + credentials, + entitlements, + extraEnv, + }: { + scheme: string; + buildConfiguration?: string; + credentials: Credentials | null; + entitlements: object | null; + extraEnv?: Env; + } +): Promise { + await ensureGymfileExists(ctx, { + scheme, + buildConfiguration, + credentials, + logsDirectory: ctx.buildLogsDirectory, + entitlements, + }); + if (ctx.skipNativeBuild) { + throw new SkipNativeBuildError('Skipping fastlane build'); + } + const buildLogger = new XcodeBuildLogger(ctx.logger, ctx.getReactNativeProjectDirectory()); + void buildLogger.watchLogFiles(ctx.buildLogsDirectory); + try { + await runFastlane(['gym'], { + cwd: path.join(ctx.getReactNativeProjectDirectory(), 'ios'), + logger: ctx.logger, + env: { ...ctx.env, ...extraEnv }, + }); + } finally { + await buildLogger.flush(); + } +} + +export async function runFastlaneResign( + ctx: BuildContext, + { credentials, ipaPath }: { credentials: Credentials; ipaPath: string } +): Promise { + const { certificateCommonName } = credentials.applicationTargetProvisioningProfile.data; + + const fastlaneDirPath = path.join(ctx.buildDirectory, 'fastlane'); + await fs.ensureDir(fastlaneDirPath); + const fastfilePath = path.join(fastlaneDirPath, 'Fastfile'); + await createFastfileForResigningBuild({ + outputFile: fastfilePath, + ipaPath, + keychainPath: credentials.keychainPath, + signingIdentity: certificateCommonName, + targetProvisioningProfiles: credentials.targetProvisioningProfiles, + }); + + await runFastlane(['do_resign'], { + cwd: ctx.buildDirectory, + logger: ctx.logger, + env: ctx.env, + }); +} + +export async function runFastlane( + fastlaneArgs: string[], + { + logger, + env, + cwd, + }: { + logger?: bunyan; + env?: Record; + cwd?: string; + } = {} +): Promise { + return await spawn('fastlane', fastlaneArgs, { + env: { + ...COMMON_FASTLANE_ENV, + ...(env ?? process.env), + }, + logger, + cwd, + }); +} + +async function ensureGymfileExists( + ctx: BuildContext, + { + scheme, + buildConfiguration, + credentials, + logsDirectory, + entitlements, + }: { + scheme: string; + buildConfiguration?: string; + credentials: Credentials | null; + logsDirectory: string; + entitlements: object | null; + } +): Promise { + const gymfilePath = path.join(ctx.getReactNativeProjectDirectory(), 'ios/Gymfile'); + + if (await fs.pathExists(gymfilePath)) { + ctx.logger.info('Gymfile already exists'); + return; + } + + ctx.logger.info('Creating Gymfile'); + if (ctx.job.simulator) { + const isTV = await isTVOS(ctx); + const simulatorDestination = `generic/platform=${isTV ? 'tvOS' : 'iOS'} Simulator`; + + await createGymfileForSimulatorBuild({ + outputFile: gymfilePath, + scheme, + buildConfiguration: buildConfiguration ?? 'release', + derivedDataPath: './build', + clean: false, + logsDirectory, + simulatorDestination, + }); + } else { + await createGymfileForArchiveBuild({ + outputFile: gymfilePath, + credentials: nullthrows(credentials, 'credentials must exist for non-simulator builds'), + scheme, + buildConfiguration, + outputDirectory: './build', + clean: false, + logsDirectory, + entitlements: entitlements ?? undefined, + }); + } + + ctx.logger.info('Gymfile created'); +} diff --git a/packages/build-tools/src/ios/gymfile.ts b/packages/build-tools/src/ios/gymfile.ts new file mode 100644 index 0000000000..c9c78b3738 --- /dev/null +++ b/packages/build-tools/src/ios/gymfile.ts @@ -0,0 +1,111 @@ +import { templateString } from '@expo/template-file'; +import fs from 'fs-extra'; + +import { GymfileArchiveTemplate } from '../templates/GymfileArchive'; +import { GymfileSimulatorTemplate } from '../templates/GymfileSimulator'; + +import { Credentials } from './credentials/manager'; + +interface ArchiveBuildOptions { + outputFile: string; + credentials: Credentials; + scheme: string; + buildConfiguration?: string; + outputDirectory: string; + clean: boolean; + logsDirectory: string; + entitlements?: object; +} + +interface SimulatorBuildOptions { + outputFile: string; + scheme: string; + buildConfiguration?: string; + derivedDataPath: string; + clean: boolean; + logsDirectory: string; + simulatorDestination: string; +} + +export async function createGymfileForArchiveBuild({ + outputFile, + clean, + credentials, + scheme, + buildConfiguration, + entitlements, + outputDirectory, + logsDirectory, +}: ArchiveBuildOptions): Promise { + const PROFILES: { BUNDLE_ID: string; UUID: string }[] = []; + const targets = Object.keys(credentials.targetProvisioningProfiles); + for (const target of targets) { + const profile = credentials.targetProvisioningProfiles[target]; + PROFILES.push({ + BUNDLE_ID: profile.bundleIdentifier, + UUID: profile.uuid, + }); + } + + const ICLOUD_CONTAINER_ENVIRONMENT = ( + entitlements as Record> + )?.['com.apple.developer.icloud-container-environment'] as string | undefined; + + await fs.mkdirp(logsDirectory); + await createGymfile({ + template: GymfileArchiveTemplate, + outputFile, + vars: { + KEYCHAIN_PATH: credentials.keychainPath, + SCHEME: scheme, + SCHEME_BUILD_CONFIGURATION: buildConfiguration, + OUTPUT_DIRECTORY: outputDirectory, + EXPORT_METHOD: credentials.distributionType, + CLEAN: String(clean), + LOGS_DIRECTORY: logsDirectory, + PROFILES, + ICLOUD_CONTAINER_ENVIRONMENT, + }, + }); +} + +export async function createGymfileForSimulatorBuild({ + outputFile, + clean, + scheme, + buildConfiguration, + derivedDataPath, + logsDirectory, + simulatorDestination, +}: SimulatorBuildOptions): Promise { + await fs.mkdirp(logsDirectory); + await createGymfile({ + template: GymfileSimulatorTemplate, + outputFile, + vars: { + SCHEME: scheme, + SCHEME_BUILD_CONFIGURATION: buildConfiguration, + SCHEME_SIMULATOR_DESTINATION: simulatorDestination, + DERIVED_DATA_PATH: derivedDataPath, + CLEAN: String(clean), + LOGS_DIRECTORY: logsDirectory, + }, + }); +} + +async function createGymfile({ + template, + outputFile, + vars, +}: { + template: string; + outputFile: string; + vars: Record; +}): Promise { + const output = templateString({ + input: template, + vars, + mustache: false, + }); + await fs.writeFile(outputFile, output); +} diff --git a/packages/build-tools/src/ios/pod.ts b/packages/build-tools/src/ios/pod.ts new file mode 100644 index 0000000000..d18c308c3a --- /dev/null +++ b/packages/build-tools/src/ios/pod.ts @@ -0,0 +1,38 @@ +import path from 'path'; + +import { Ios } from '@expo/eas-build-job'; +import spawn, { SpawnOptions, SpawnPromise, SpawnResult } from '@expo/turtle-spawn'; + +import { BuildContext } from '../context'; + +export async function installPods( + ctx: BuildContext, + { infoCallbackFn }: SpawnOptions +): Promise<{ spawnPromise: SpawnPromise }> { + const iosDir = path.join(ctx.getReactNativeProjectDirectory(), 'ios'); + + const verboseFlag = ctx.env['EAS_VERBOSE'] === '1' ? ['--verbose'] : []; + const cocoapodsDeploymentFlag = ctx.env['POD_INSTALL_DEPLOYMENT'] === '1' ? ['--deployment'] : []; + + return { + spawnPromise: spawn('pod', ['install', ...verboseFlag, ...cocoapodsDeploymentFlag], { + cwd: iosDir, + logger: ctx.logger, + env: { + ...ctx.env, + LANG: 'en_US.UTF-8', + }, + lineTransformer: (line?: string) => { + if ( + !line || + /\[!\] '[\w-]+' uses the unencrypted 'http' protocol to transfer the Pod\./.exec(line) + ) { + return null; + } else { + return line; + } + }, + infoCallbackFn, + }), + }; +} diff --git a/packages/build-tools/src/ios/resign.ts b/packages/build-tools/src/ios/resign.ts new file mode 100644 index 0000000000..e605db0428 --- /dev/null +++ b/packages/build-tools/src/ios/resign.ts @@ -0,0 +1,29 @@ +import assert from 'assert'; +import path from 'path'; + +import downloadFile from '@expo/downloader'; +import { ArchiveSourceType, Ios } from '@expo/eas-build-job'; + +import { BuildContext } from '../context'; + +export async function downloadApplicationArchiveAsync(ctx: BuildContext): Promise { + assert(ctx.job.resign); + + const applicationArchivePath = path.join(ctx.workingdir, 'application.ipa'); + + const { applicationArchiveSource } = ctx.job.resign; + if (applicationArchiveSource.type === ArchiveSourceType.URL) { + try { + await downloadFile(applicationArchiveSource.url, applicationArchivePath, { retry: 3 }); + } catch (err: any) { + ctx.reportError?.('Failed to download the application archive', err, { + extras: { buildId: ctx.env.EAS_BUILD_ID }, + }); + throw err; + } + } else { + throw new Error('Only application archive URLs are supported'); + } + + return applicationArchivePath; +} diff --git a/packages/build-tools/src/ios/resolve.ts b/packages/build-tools/src/ios/resolve.ts new file mode 100644 index 0000000000..a1cb460e0d --- /dev/null +++ b/packages/build-tools/src/ios/resolve.ts @@ -0,0 +1,37 @@ +import assert from 'assert'; + +import { Ios } from '@expo/eas-build-job'; +import { IOSConfig } from '@expo/config-plugins'; + +import { BuildContext } from '../context'; + +export function resolveScheme(ctx: BuildContext): string { + if (ctx.job.scheme) { + return ctx.job.scheme; + } + const schemes = IOSConfig.BuildScheme.getSchemesFromXcodeproj( + ctx.getReactNativeProjectDirectory() + ); + assert(schemes.length === 1, 'Ejected project should have exactly one scheme'); + return schemes[0]; +} + +export function resolveArtifactPath(ctx: BuildContext): string { + if (ctx.job.applicationArchivePath) { + return ctx.job.applicationArchivePath; + } else if (ctx.job.simulator) { + return 'ios/build/Build/Products/*simulator/*.app'; + } else { + return 'ios/build/*.ipa'; + } +} + +export function resolveBuildConfiguration(ctx: BuildContext): string { + if (ctx.job.buildConfiguration) { + return ctx.job.buildConfiguration; + } else if (ctx.job.developmentClient) { + return 'Debug'; + } else { + return 'Release'; + } +} diff --git a/packages/build-tools/src/ios/tvos.ts b/packages/build-tools/src/ios/tvos.ts new file mode 100644 index 0000000000..9021c4859c --- /dev/null +++ b/packages/build-tools/src/ios/tvos.ts @@ -0,0 +1,33 @@ +import { Ios } from '@expo/eas-build-job'; +import { IOSConfig } from '@expo/config-plugins'; + +import { BuildContext } from '../context'; + +import { resolveBuildConfiguration, resolveScheme } from './resolve'; + +// Functions specific to Apple TV support should be added here + +/** + * Use XcodeUtils to check if a build configuration is for Apple TV and not iOS + * + * @param ctx The build context + * @returns true if this is an Apple TV configuration, false otherwise + */ +export async function isTVOS(ctx: BuildContext): Promise { + const scheme = resolveScheme(ctx); + + const project = IOSConfig.XcodeUtils.getPbxproj(ctx.getReactNativeProjectDirectory()); + + const targetName = await IOSConfig.BuildScheme.getApplicationTargetNameForSchemeAsync( + ctx.getReactNativeProjectDirectory(), + scheme + ); + + const buildConfiguration = resolveBuildConfiguration(ctx); + + const xcBuildConfiguration = IOSConfig.Target.getXCBuildConfigurationFromPbxproj(project, { + targetName, + buildConfiguration, + }); + return xcBuildConfiguration?.buildSettings?.SDKROOT?.includes('appletv'); +} diff --git a/packages/build-tools/src/ios/xcodeBuildLogs.ts b/packages/build-tools/src/ios/xcodeBuildLogs.ts new file mode 100644 index 0000000000..d1758a791b --- /dev/null +++ b/packages/build-tools/src/ios/xcodeBuildLogs.ts @@ -0,0 +1,44 @@ +import os from 'os'; +import path from 'path'; + +import { ManagedArtifactType, Ios } from '@expo/eas-build-job'; +import fg from 'fast-glob'; +import { bunyan } from '@expo/logger'; + +import { BuildContext } from '../context'; + +export async function findAndUploadXcodeBuildLogsAsync( + ctx: BuildContext, + { logger }: { logger: bunyan } +): Promise { + try { + const xcodeBuildLogsPath = await findXcodeBuildLogsPathAsync(ctx.buildLogsDirectory); + if (xcodeBuildLogsPath) { + await ctx.uploadArtifact({ + artifact: { + type: ManagedArtifactType.XCODE_BUILD_LOGS, + paths: [xcodeBuildLogsPath], + }, + logger, + }); + } + } catch (err: any) { + logger.debug({ err }, 'Failed to upload Xcode build logs'); + } +} + +export async function findXcodeBuildLogsPathAsync( + buildLogsDirectory: string +): Promise { + const customLogPaths = (await fg('*.log', { cwd: buildLogsDirectory })).map((filename) => + path.join(buildLogsDirectory, filename) + ); + if (customLogPaths[0]) { + return customLogPaths[0]; + } + const fallbackLogPaths = (await fg('Library/Logs/gym/*.log', { cwd: os.homedir() })).map( + (relativePath) => path.join(os.homedir(), relativePath) + ); + + return customLogPaths[0] ?? fallbackLogPaths[0] ?? undefined; +} diff --git a/packages/build-tools/src/ios/xcodeEnv.ts b/packages/build-tools/src/ios/xcodeEnv.ts new file mode 100644 index 0000000000..1fedf3bb7c --- /dev/null +++ b/packages/build-tools/src/ios/xcodeEnv.ts @@ -0,0 +1,21 @@ +import path from 'path'; + +import { Ios } from '@expo/eas-build-job'; +import fs from 'fs-extra'; + +import { BuildContext } from '../context'; + +export async function deleteXcodeEnvLocalIfExistsAsync(ctx: BuildContext): Promise { + const xcodeEnvLocalPath = path.join( + ctx.getReactNativeProjectDirectory(), + 'ios', + '.xcode.env.local' + ); + if (await fs.pathExists(xcodeEnvLocalPath)) { + ctx.markBuildPhaseHasWarnings(); + ctx.logger.warn( + `Detected and removed file: ios/.xcode.env.local. This file should not be committed to source control. Learn more: https://expo.fyi/xcode-env-local` + ); + await fs.remove(xcodeEnvLocalPath); + } +} diff --git a/packages/build-tools/src/ios/xcpretty.ts b/packages/build-tools/src/ios/xcpretty.ts new file mode 100644 index 0000000000..e6b48c425f --- /dev/null +++ b/packages/build-tools/src/ios/xcpretty.ts @@ -0,0 +1,92 @@ +import assert from 'assert'; +import path from 'path'; + +import fs from 'fs-extra'; +import { bunyan } from '@expo/logger'; +import { ExpoRunFormatter } from '@expo/xcpretty'; +import spawnAsync, { SpawnPromise, SpawnResult } from '@expo/spawn-async'; +import fg from 'fast-glob'; + +const CHECK_FILE_INTERVAL_MS = 1000; + +export class XcodeBuildLogger { + private loggerError?: Error; + private flushing: boolean = false; + private logReaderPromise?: SpawnPromise; + private logsPath?: string; + + constructor( + private readonly logger: bunyan, + private readonly projectRoot: string + ) {} + + public async watchLogFiles(logsDirectory: string): Promise { + while (!this.flushing) { + const logsFilename = await this.getBuildLogFilename(logsDirectory); + if (logsFilename) { + this.logsPath = path.join(logsDirectory, logsFilename); + void this.startBuildLogger(this.logsPath); + return; + } + await new Promise((res) => setTimeout(res, CHECK_FILE_INTERVAL_MS)); + } + } + + public async flush(): Promise { + this.flushing = true; + if (this.loggerError) { + throw this.loggerError; + } + if (this.logReaderPromise) { + this.logReaderPromise.child.kill('SIGINT'); + try { + await this.logReaderPromise; + } catch {} + } + if (this.logsPath) { + await this.findBundlerErrors(this.logsPath); + } + } + private async getBuildLogFilename(logsDirectory: string): Promise { + const paths = await fg('*.log', { cwd: logsDirectory }); + return paths.length >= 1 ? paths[0] : undefined; + } + + private async startBuildLogger(logsPath: string): Promise { + try { + const formatter = ExpoRunFormatter.create(this.projectRoot, { + // TODO: Can provide xcode project name for better parsing + isDebug: false, + }); + this.logReaderPromise = spawnAsync('tail', ['-n', '+0', '-f', logsPath], { stdio: 'pipe' }); + assert(this.logReaderPromise.child.stdout, 'stdout is not available'); + this.logReaderPromise.child.stdout.on('data', (data: string) => { + const lines = formatter.pipe(data.toString()); + for (const line of lines) { + this.logger.info(line); + } + }); + await this.logReaderPromise; + + this.logger.info(formatter.getBuildSummary()); + } catch (err: any) { + if (!this.flushing) { + this.loggerError = err; + } + } + } + + private async findBundlerErrors(logsPath: string): Promise { + try { + const logFile = await fs.readFile(logsPath, 'utf-8'); + const match = logFile.match( + /Welcome to Metro!\s* Fast - Scalable - Integrated\s*([\s\S]*)Run CLI with --verbose flag for more details.\nCommand PhaseScriptExecution failed with a nonzero exit code/ + ); + if (match) { + this.logger.info(match[1]); + } + } catch (err) { + this.logger.error({ err }, 'Failed to read Xcode logs'); + } + } +} diff --git a/packages/build-tools/src/steps/easFunctionGroups.ts b/packages/build-tools/src/steps/easFunctionGroups.ts new file mode 100644 index 0000000000..664ab51038 --- /dev/null +++ b/packages/build-tools/src/steps/easFunctionGroups.ts @@ -0,0 +1,16 @@ +import { BuildFunctionGroup } from '@expo/steps'; + +import { CustomBuildContext } from '../customBuildContext'; + +import { createEasBuildBuildFunctionGroup } from './functionGroups/build'; +import { createEasMaestroTestFunctionGroup } from './functionGroups/maestroTest'; + +export function getEasFunctionGroups(ctx: CustomBuildContext): BuildFunctionGroup[] { + const functionGroups = [createEasMaestroTestFunctionGroup(ctx)]; + + if (ctx.hasBuildJob()) { + functionGroups.push(...[createEasBuildBuildFunctionGroup(ctx)]); + } + + return functionGroups; +} diff --git a/packages/build-tools/src/steps/easFunctions.ts b/packages/build-tools/src/steps/easFunctions.ts new file mode 100644 index 0000000000..807f537b4e --- /dev/null +++ b/packages/build-tools/src/steps/easFunctions.ts @@ -0,0 +1,86 @@ +import { BuildFunction } from '@expo/steps'; + +import { CustomBuildContext } from '../customBuildContext'; + +import { createUploadArtifactBuildFunction } from './functions/uploadArtifact'; +import { createCheckoutBuildFunction } from './functions/checkout'; +import { createSetUpNpmrcBuildFunction } from './functions/useNpmToken'; +import { createInstallNodeModulesBuildFunction } from './functions/installNodeModules'; +import { createPrebuildBuildFunction } from './functions/prebuild'; +import { createFindAndUploadBuildArtifactsBuildFunction } from './functions/findAndUploadBuildArtifacts'; +import { configureEASUpdateIfInstalledFunction } from './functions/configureEASUpdateIfInstalled'; +import { injectAndroidCredentialsFunction } from './functions/injectAndroidCredentials'; +import { configureAndroidVersionFunction } from './functions/configureAndroidVersion'; +import { runGradleFunction } from './functions/runGradle'; +import { resolveAppleTeamIdFromCredentialsFunction } from './functions/resolveAppleTeamIdFromCredentials'; +import { configureIosCredentialsFunction } from './functions/configureIosCredentials'; +import { configureIosVersionFunction } from './functions/configureIosVersion'; +import { generateGymfileFromTemplateFunction } from './functions/generateGymfileFromTemplate'; +import { runFastlaneFunction } from './functions/runFastlane'; +import { createStartAndroidEmulatorBuildFunction } from './functions/startAndroidEmulator'; +import { createStartIosSimulatorBuildFunction } from './functions/startIosSimulator'; +import { createInstallMaestroBuildFunction } from './functions/installMaestro'; +import { createGetCredentialsForBuildTriggeredByGithubIntegration } from './functions/getCredentialsForBuildTriggeredByGitHubIntegration'; +import { createInstallPodsBuildFunction } from './functions/installPods'; +import { createSendSlackMessageFunction } from './functions/sendSlackMessage'; +import { createResolveBuildConfigBuildFunction } from './functions/resolveBuildConfig'; +import { calculateEASUpdateRuntimeVersionFunction } from './functions/calculateEASUpdateRuntimeVersion'; +import { eagerBundleBuildFunction } from './functions/eagerBundle'; +import { createSubmissionEntityFunction } from './functions/createSubmissionEntity'; +import { createDownloadBuildFunction } from './functions/downloadBuild'; +import { createRepackBuildFunction } from './functions/repack'; +import { createDownloadArtifactFunction } from './functions/downloadArtifact'; +import { createRestoreCacheFunction } from './functions/restoreCache'; +import { createSaveCacheFunction } from './functions/saveCache'; +import { createInternalEasMaestroTestFunction } from './functions/internalMaestroTest'; + +export function getEasFunctions(ctx: CustomBuildContext): BuildFunction[] { + const functions = [ + createCheckoutBuildFunction(), + createDownloadArtifactFunction(), + createUploadArtifactBuildFunction(ctx), + createSetUpNpmrcBuildFunction(), + createInstallNodeModulesBuildFunction(), + createPrebuildBuildFunction(), + createDownloadBuildFunction(), + createRepackBuildFunction(), + + createRestoreCacheFunction(), + createSaveCacheFunction(), + + configureEASUpdateIfInstalledFunction(), + injectAndroidCredentialsFunction(), + configureAndroidVersionFunction(), + eagerBundleBuildFunction(), + runGradleFunction(), + resolveAppleTeamIdFromCredentialsFunction(), + configureIosCredentialsFunction(), + configureIosVersionFunction(), + generateGymfileFromTemplateFunction(), + runFastlaneFunction(), + createStartAndroidEmulatorBuildFunction(), + createStartIosSimulatorBuildFunction(), + createInstallMaestroBuildFunction(), + + createInstallPodsBuildFunction(), + createSendSlackMessageFunction(), + + calculateEASUpdateRuntimeVersionFunction(), + + createSubmissionEntityFunction(), + + createInternalEasMaestroTestFunction(ctx), + ]; + + if (ctx.hasBuildJob()) { + functions.push( + ...[ + createFindAndUploadBuildArtifactsBuildFunction(ctx), + createResolveBuildConfigBuildFunction(ctx), + createGetCredentialsForBuildTriggeredByGithubIntegration(ctx), + ] + ); + } + + return functions; +} diff --git a/packages/build-tools/src/steps/functionGroups/build.ts b/packages/build-tools/src/steps/functionGroups/build.ts new file mode 100644 index 0000000000..35098123c1 --- /dev/null +++ b/packages/build-tools/src/steps/functionGroups/build.ts @@ -0,0 +1,366 @@ +import { BuildFunctionGroup, BuildStep, BuildStepGlobalContext } from '@expo/steps'; +import { BuildJob, Platform } from '@expo/eas-build-job'; + +import { createCheckoutBuildFunction } from '../functions/checkout'; +import { createInstallNodeModulesBuildFunction } from '../functions/installNodeModules'; +import { createPrebuildBuildFunction } from '../functions/prebuild'; +import { createInstallPodsBuildFunction } from '../functions/installPods'; +import { configureEASUpdateIfInstalledFunction } from '../functions/configureEASUpdateIfInstalled'; +import { generateGymfileFromTemplateFunction } from '../functions/generateGymfileFromTemplate'; +import { runFastlaneFunction } from '../functions/runFastlane'; +import { createFindAndUploadBuildArtifactsBuildFunction } from '../functions/findAndUploadBuildArtifacts'; +import { CustomBuildContext } from '../../customBuildContext'; +import { resolveAppleTeamIdFromCredentialsFunction } from '../functions/resolveAppleTeamIdFromCredentials'; +import { configureIosCredentialsFunction } from '../functions/configureIosCredentials'; +import { runGradleFunction } from '../functions/runGradle'; +import { configureIosVersionFunction } from '../functions/configureIosVersion'; +import { injectAndroidCredentialsFunction } from '../functions/injectAndroidCredentials'; +import { configureAndroidVersionFunction } from '../functions/configureAndroidVersion'; +import { createSetUpNpmrcBuildFunction } from '../functions/useNpmToken'; +import { createResolveBuildConfigBuildFunction } from '../functions/resolveBuildConfig'; +import { calculateEASUpdateRuntimeVersionFunction } from '../functions/calculateEASUpdateRuntimeVersion'; +import { eagerBundleBuildFunction } from '../functions/eagerBundle'; +import { shouldUseEagerBundle } from '../../common/eagerBundle'; +import { + createRestoreBuildCacheFunction, + createCacheStatsBuildFunction, +} from '../functions/restoreBuildCache'; +import { createSaveBuildCacheFunction } from '../functions/saveBuildCache'; + +interface HelperFunctionsInput { + globalCtx: BuildStepGlobalContext; + buildToolsContext: CustomBuildContext; +} + +export function createEasBuildBuildFunctionGroup( + buildToolsContext: CustomBuildContext +): BuildFunctionGroup { + return new BuildFunctionGroup({ + namespace: 'eas', + id: 'build', + createBuildStepsFromFunctionGroupCall: (globalCtx) => { + if (buildToolsContext.job.platform === Platform.IOS) { + if (buildToolsContext.job.simulator) { + return createStepsForIosSimulatorBuild({ + globalCtx, + buildToolsContext, + }); + } else { + return createStepsForIosBuildWithCredentials({ + globalCtx, + buildToolsContext, + }); + } + } else if (buildToolsContext.job.platform === Platform.ANDROID) { + if (!buildToolsContext.job.secrets?.buildCredentials) { + return createStepsForAndroidBuildWithoutCredentials({ + globalCtx, + buildToolsContext, + }); + } else { + return createStepsForAndroidBuildWithCredentials({ + globalCtx, + buildToolsContext, + }); + } + } + + throw new Error('Build function group is not supported in generic jobs.'); + }, + }); +} + +function createStepsForIosSimulatorBuild({ + globalCtx, + buildToolsContext, +}: HelperFunctionsInput): BuildStep[] { + const calculateEASUpdateRuntimeVersion = + calculateEASUpdateRuntimeVersionFunction().createBuildStepFromFunctionCall(globalCtx, { + id: 'calculate_eas_update_runtime_version', + }); + const installPods = createInstallPodsBuildFunction().createBuildStepFromFunctionCall(globalCtx, { + workingDirectory: './ios', + }); + const configureEASUpdate = + configureEASUpdateIfInstalledFunction().createBuildStepFromFunctionCall(globalCtx, { + callInputs: { + throw_if_not_configured: false, + resolved_eas_update_runtime_version: + '${ steps.calculate_eas_update_runtime_version.resolved_eas_update_runtime_version }', + }, + }); + const runFastlane = runFastlaneFunction().createBuildStepFromFunctionCall(globalCtx, { + id: 'run_fastlane', + callInputs: { + resolved_eas_update_runtime_version: + '${ steps.calculate_eas_update_runtime_version.resolved_eas_update_runtime_version }', + }, + }); + return [ + createCheckoutBuildFunction().createBuildStepFromFunctionCall(globalCtx), + createSetUpNpmrcBuildFunction().createBuildStepFromFunctionCall(globalCtx), + createInstallNodeModulesBuildFunction().createBuildStepFromFunctionCall(globalCtx), + createResolveBuildConfigBuildFunction(buildToolsContext).createBuildStepFromFunctionCall( + globalCtx + ), + createPrebuildBuildFunction().createBuildStepFromFunctionCall(globalCtx), + calculateEASUpdateRuntimeVersion, + installPods, + configureEASUpdate, + ...(shouldUseEagerBundle(globalCtx.staticContext.metadata) + ? [ + eagerBundleBuildFunction().createBuildStepFromFunctionCall(globalCtx, { + callInputs: { + resolved_eas_update_runtime_version: + '${ steps.calculate_eas_update_runtime_version.resolved_eas_update_runtime_version }', + }, + }), + ] + : []), + generateGymfileFromTemplateFunction().createBuildStepFromFunctionCall(globalCtx), + runFastlane, + createFindAndUploadBuildArtifactsBuildFunction( + buildToolsContext + ).createBuildStepFromFunctionCall(globalCtx), + ]; +} + +function createStepsForIosBuildWithCredentials({ + globalCtx, + buildToolsContext, +}: HelperFunctionsInput): BuildStep[] { + const evictUsedBefore = new Date(); + + const resolveAppleTeamIdFromCredentials = + resolveAppleTeamIdFromCredentialsFunction().createBuildStepFromFunctionCall(globalCtx, { + id: 'resolve_apple_team_id_from_credentials', + }); + const calculateEASUpdateRuntimeVersion = + calculateEASUpdateRuntimeVersionFunction().createBuildStepFromFunctionCall(globalCtx, { + id: 'calculate_eas_update_runtime_version', + }); + const prebuildStep = createPrebuildBuildFunction().createBuildStepFromFunctionCall(globalCtx, { + callInputs: { + apple_team_id: '${ steps.resolve_apple_team_id_from_credentials.apple_team_id }', + }, + }); + const restoreCache = createRestoreBuildCacheFunction().createBuildStepFromFunctionCall( + globalCtx, + { + callInputs: { + platform: Platform.IOS, + }, + } + ); + const installPods = createInstallPodsBuildFunction().createBuildStepFromFunctionCall(globalCtx, { + workingDirectory: './ios', + }); + const configureEASUpdate = + configureEASUpdateIfInstalledFunction().createBuildStepFromFunctionCall(globalCtx, { + callInputs: { + throw_if_not_configured: false, + resolved_eas_update_runtime_version: + '${ steps.calculate_eas_update_runtime_version.resolved_eas_update_runtime_version }', + }, + }); + const generateGymfile = generateGymfileFromTemplateFunction().createBuildStepFromFunctionCall( + globalCtx, + { + callInputs: { + credentials: '${ eas.job.secrets.buildCredentials }', + }, + } + ); + const runFastlane = runFastlaneFunction().createBuildStepFromFunctionCall(globalCtx, { + id: 'run_fastlane', + callInputs: { + resolved_eas_update_runtime_version: + '${ steps.calculate_eas_update_runtime_version.resolved_eas_update_runtime_version }', + }, + }); + const saveCache = createSaveBuildCacheFunction(evictUsedBefore).createBuildStepFromFunctionCall( + globalCtx, + { + callInputs: { + platform: Platform.IOS, + }, + } + ); + return [ + createCheckoutBuildFunction().createBuildStepFromFunctionCall(globalCtx), + createSetUpNpmrcBuildFunction().createBuildStepFromFunctionCall(globalCtx), + createInstallNodeModulesBuildFunction().createBuildStepFromFunctionCall(globalCtx), + createResolveBuildConfigBuildFunction(buildToolsContext).createBuildStepFromFunctionCall( + globalCtx + ), + resolveAppleTeamIdFromCredentials, + prebuildStep, + restoreCache, + calculateEASUpdateRuntimeVersion, + installPods, + configureEASUpdate, + configureIosCredentialsFunction().createBuildStepFromFunctionCall(globalCtx), + configureIosVersionFunction().createBuildStepFromFunctionCall(globalCtx), + ...(shouldUseEagerBundle(globalCtx.staticContext.metadata) + ? [ + eagerBundleBuildFunction().createBuildStepFromFunctionCall(globalCtx, { + callInputs: { + resolved_eas_update_runtime_version: + '${ steps.calculate_eas_update_runtime_version.resolved_eas_update_runtime_version }', + }, + }), + ] + : []), + generateGymfile, + runFastlane, + createFindAndUploadBuildArtifactsBuildFunction( + buildToolsContext + ).createBuildStepFromFunctionCall(globalCtx), + saveCache, + createCacheStatsBuildFunction().createBuildStepFromFunctionCall(globalCtx), + ]; +} + +function createStepsForAndroidBuildWithoutCredentials({ + globalCtx, + buildToolsContext, +}: HelperFunctionsInput): BuildStep[] { + const evictUsedBefore = new Date(); + + const calculateEASUpdateRuntimeVersion = + calculateEASUpdateRuntimeVersionFunction().createBuildStepFromFunctionCall(globalCtx, { + id: 'calculate_eas_update_runtime_version', + }); + const configureEASUpdate = + configureEASUpdateIfInstalledFunction().createBuildStepFromFunctionCall(globalCtx, { + callInputs: { + throw_if_not_configured: false, + resolved_eas_update_runtime_version: + '${ steps.calculate_eas_update_runtime_version.resolved_eas_update_runtime_version }', + }, + }); + const restoreCache = createRestoreBuildCacheFunction().createBuildStepFromFunctionCall( + globalCtx, + { + callInputs: { + platform: Platform.ANDROID, + }, + } + ); + const runGradle = runGradleFunction().createBuildStepFromFunctionCall(globalCtx, { + id: 'run_gradle', + callInputs: { + resolved_eas_update_runtime_version: + '${ steps.calculate_eas_update_runtime_version.resolved_eas_update_runtime_version }', + }, + }); + const saveCache = createSaveBuildCacheFunction(evictUsedBefore).createBuildStepFromFunctionCall( + globalCtx, + { + callInputs: { + platform: Platform.ANDROID, + }, + } + ); + return [ + createCheckoutBuildFunction().createBuildStepFromFunctionCall(globalCtx), + createSetUpNpmrcBuildFunction().createBuildStepFromFunctionCall(globalCtx), + createInstallNodeModulesBuildFunction().createBuildStepFromFunctionCall(globalCtx), + createResolveBuildConfigBuildFunction(buildToolsContext).createBuildStepFromFunctionCall( + globalCtx + ), + createPrebuildBuildFunction().createBuildStepFromFunctionCall(globalCtx), + restoreCache, + calculateEASUpdateRuntimeVersion, + configureEASUpdate, + ...(shouldUseEagerBundle(globalCtx.staticContext.metadata) + ? [ + eagerBundleBuildFunction().createBuildStepFromFunctionCall(globalCtx, { + callInputs: { + resolved_eas_update_runtime_version: + '${ steps.calculate_eas_update_runtime_version.resolved_eas_update_runtime_version }', + }, + }), + ] + : []), + runGradle, + createFindAndUploadBuildArtifactsBuildFunction( + buildToolsContext + ).createBuildStepFromFunctionCall(globalCtx), + saveCache, + createCacheStatsBuildFunction().createBuildStepFromFunctionCall(globalCtx), + ]; +} + +function createStepsForAndroidBuildWithCredentials({ + globalCtx, + buildToolsContext, +}: HelperFunctionsInput): BuildStep[] { + const evictUsedBefore = new Date(); + + const calculateEASUpdateRuntimeVersion = + calculateEASUpdateRuntimeVersionFunction().createBuildStepFromFunctionCall(globalCtx, { + id: 'calculate_eas_update_runtime_version', + }); + const configureEASUpdate = + configureEASUpdateIfInstalledFunction().createBuildStepFromFunctionCall(globalCtx, { + callInputs: { + throw_if_not_configured: false, + resolved_eas_update_runtime_version: + '${ steps.calculate_eas_update_runtime_version.resolved_eas_update_runtime_version }', + }, + }); + const restoreCache = createRestoreBuildCacheFunction().createBuildStepFromFunctionCall( + globalCtx, + { + callInputs: { + platform: Platform.ANDROID, + }, + } + ); + const runGradle = runGradleFunction().createBuildStepFromFunctionCall(globalCtx, { + id: 'run_gradle', + callInputs: { + resolved_eas_update_runtime_version: + '${ steps.calculate_eas_update_runtime_version.resolved_eas_update_runtime_version }', + }, + }); + const saveCache = createSaveBuildCacheFunction(evictUsedBefore).createBuildStepFromFunctionCall( + globalCtx, + { + callInputs: { + platform: Platform.ANDROID, + }, + } + ); + return [ + createCheckoutBuildFunction().createBuildStepFromFunctionCall(globalCtx), + createSetUpNpmrcBuildFunction().createBuildStepFromFunctionCall(globalCtx), + createInstallNodeModulesBuildFunction().createBuildStepFromFunctionCall(globalCtx), + createResolveBuildConfigBuildFunction(buildToolsContext).createBuildStepFromFunctionCall( + globalCtx + ), + createPrebuildBuildFunction().createBuildStepFromFunctionCall(globalCtx), + restoreCache, + calculateEASUpdateRuntimeVersion, + configureEASUpdate, + injectAndroidCredentialsFunction().createBuildStepFromFunctionCall(globalCtx), + configureAndroidVersionFunction().createBuildStepFromFunctionCall(globalCtx), + runGradle, + ...(shouldUseEagerBundle(globalCtx.staticContext.metadata) + ? [ + eagerBundleBuildFunction().createBuildStepFromFunctionCall(globalCtx, { + callInputs: { + resolved_eas_update_runtime_version: + '${ steps.calculate_eas_update_runtime_version.resolved_eas_update_runtime_version }', + }, + }), + ] + : []), + createFindAndUploadBuildArtifactsBuildFunction( + buildToolsContext + ).createBuildStepFromFunctionCall(globalCtx), + saveCache, + createCacheStatsBuildFunction().createBuildStepFromFunctionCall(globalCtx), + ]; +} diff --git a/packages/build-tools/src/steps/functionGroups/maestroTest.ts b/packages/build-tools/src/steps/functionGroups/maestroTest.ts new file mode 100644 index 0000000000..547bba0c1c --- /dev/null +++ b/packages/build-tools/src/steps/functionGroups/maestroTest.ts @@ -0,0 +1,163 @@ +import { + BuildFunctionGroup, + BuildStep, + BuildStepInput, + BuildStepInputValueTypeName, +} from '@expo/steps'; +import { Platform } from '@expo/eas-build-job'; + +import { CustomBuildContext } from '../../customBuildContext'; +import { createInstallMaestroBuildFunction } from '../functions/installMaestro'; +import { createStartIosSimulatorBuildFunction } from '../functions/startIosSimulator'; +import { createStartAndroidEmulatorBuildFunction } from '../functions/startAndroidEmulator'; +import { createUploadArtifactBuildFunction } from '../functions/uploadArtifact'; + +export function createEasMaestroTestFunctionGroup( + buildToolsContext: CustomBuildContext +): BuildFunctionGroup { + return new BuildFunctionGroup({ + namespace: 'eas', + id: 'maestro_test', + inputProviders: [ + BuildStepInput.createProvider({ + allowedValueTypeName: BuildStepInputValueTypeName.STRING, + id: 'flow_path', + required: true, + }), + BuildStepInput.createProvider({ + allowedValueTypeName: BuildStepInputValueTypeName.STRING, + id: 'app_path', + required: false, + }), + BuildStepInput.createProvider({ + allowedValueTypeName: BuildStepInputValueTypeName.STRING, + id: 'android_emulator_system_image_package', + required: false, + }), + ], + createBuildStepsFromFunctionGroupCall: (globalCtx, { inputs }) => { + const steps: BuildStep[] = [ + createInstallMaestroBuildFunction().createBuildStepFromFunctionCall(globalCtx), + ]; + + if (buildToolsContext.job.platform === Platform.IOS) { + steps.push( + createStartIosSimulatorBuildFunction().createBuildStepFromFunctionCall(globalCtx) + ); + const searchPath = + inputs.app_path.getValue({ + interpolationContext: globalCtx.getInterpolationContext(), + }) ?? 'ios/build/Build/Products/*simulator/*.app'; + steps.push( + new BuildStep(globalCtx, { + id: BuildStep.getNewId(), + name: 'install_app', + displayName: `Install app to Simulator`, + command: ` + # shopt -s nullglob is necessary not to try to install + # SEARCH_PATH literally if there are no matching files. + shopt -s nullglob + + SEARCH_PATH="${searchPath}" + FILES_FOUND=false + + for APP_PATH in $SEARCH_PATH; do + FILES_FOUND=true + echo "Installing \\"$APP_PATH\\"" + xcrun simctl install booted "$APP_PATH" + done + + if ! $FILES_FOUND; then + echo "No files found matching \\"$SEARCH_PATH\\". Are you sure you've built a Simulator app?" + exit 1 + fi + `, + }) + ); + } else if (buildToolsContext.job.platform === Platform.ANDROID) { + const system_image_package = inputs.android_emulator_system_image_package.getValue({ + interpolationContext: globalCtx.getInterpolationContext(), + }); + steps.push( + createStartAndroidEmulatorBuildFunction().createBuildStepFromFunctionCall( + globalCtx, + system_image_package + ? { + callInputs: { + system_image_package, + }, + } + : undefined + ) + ); + const searchPath = + inputs.app_path.getValue({ + interpolationContext: globalCtx.getInterpolationContext(), + }) ?? 'android/app/build/outputs/**/*.apk'; + steps.push( + new BuildStep(globalCtx, { + id: BuildStep.getNewId(), + name: 'install_app', + displayName: `Install app to Emulator`, + command: ` + # shopt -s globstar is necessary to add /**/ support + shopt -s globstar + # shopt -s nullglob is necessary not to try to install + # SEARCH_PATH literally if there are no matching files. + shopt -s nullglob + + SEARCH_PATH="${searchPath}" + FILES_FOUND=false + + for APP_PATH in $SEARCH_PATH; do + FILES_FOUND=true + echo "Installing \\"$APP_PATH\\"" + adb install "$APP_PATH" + done + + if ! $FILES_FOUND; then + echo "No files found matching \\"$SEARCH_PATH\\". Are you sure you've built an Emulator app?" + exit 1 + fi + `, + }) + ); + } + + const flowPaths = `${inputs.flow_path.getValue({ + interpolationContext: globalCtx.getInterpolationContext(), + })}` + .split('\n') // It's easy to get an empty line with YAML + .filter((entry) => entry); + + for (const flowPath of flowPaths) { + steps.push( + new BuildStep(globalCtx, { + id: BuildStep.getNewId(), + name: 'maestro_test', + ifCondition: '${ always() }', + displayName: `maestro test ${flowPath}`, + command: `maestro test ${flowPath}`, + }) + ); + } + + steps.push( + createUploadArtifactBuildFunction(buildToolsContext).createBuildStepFromFunctionCall( + globalCtx, + { + ifCondition: '${ always() }', + name: 'Upload Maestro test results', + callInputs: { + path: '${ eas.env.HOME }/.maestro/tests', + ignore_error: true, + type: 'build-artifact', + }, + } + ) + ); + + return steps; + }, + }); +} diff --git a/packages/build-tools/src/steps/functions/__integration-tests__/installMaestro.test.ts b/packages/build-tools/src/steps/functions/__integration-tests__/installMaestro.test.ts new file mode 100644 index 0000000000..88b8e789bd --- /dev/null +++ b/packages/build-tools/src/steps/functions/__integration-tests__/installMaestro.test.ts @@ -0,0 +1,59 @@ +import { createGlobalContextMock } from '../../../__tests__/utils/context'; +import { createMockLogger } from '../../../__tests__/utils/logger'; +import { createInstallMaestroBuildFunction } from '../installMaestro'; + +jest.unmock('fs'); +jest.unmock('@expo/logger'); + +describe('createInstallMaestroBuildFunction', () => { + it('should resolve with no parameters', async () => { + const installMaestro = createInstallMaestroBuildFunction(); + const logger = createMockLogger({ logToConsole: true }); + + const anyVersionStep = installMaestro.createBuildStepFromFunctionCall( + createGlobalContextMock({ logger }), + { + env: { + ...process.env, + EAS_BUILD_RUNNER: 'eas-build', + }, + callInputs: {}, + } + ); + + await expect(anyVersionStep.executeAsync()).resolves.not.toThrow(); + console.log(anyVersionStep.outputs[0].value); + + const downgradeStep = installMaestro.createBuildStepFromFunctionCall( + createGlobalContextMock({ logger }), + { + env: { + ...process.env, + EAS_BUILD_RUNNER: 'eas-build', + }, + callInputs: { + maestro_version: '1.40.0', + }, + } + ); + + await expect(downgradeStep.executeAsync()).resolves.not.toThrow(); + expect(downgradeStep.outputById.maestro_version.value).toBe('1.40.0'); + + const latestStep = installMaestro.createBuildStepFromFunctionCall( + createGlobalContextMock({ logger }), + { + env: { + ...process.env, + EAS_BUILD_RUNNER: 'eas-build', + }, + callInputs: { + maestro_version: 'latest', + }, + } + ); + + await expect(latestStep.executeAsync()).resolves.not.toThrow(); + console.log(latestStep.outputs[0].value); + }, 180_000); +}); diff --git a/packages/build-tools/src/steps/functions/__tests__/downloadBuild.test.ts b/packages/build-tools/src/steps/functions/__tests__/downloadBuild.test.ts new file mode 100644 index 0000000000..5df2126bf5 --- /dev/null +++ b/packages/build-tools/src/steps/functions/__tests__/downloadBuild.test.ts @@ -0,0 +1,110 @@ +import { randomUUID } from 'node:crypto'; +import fs from 'node:fs'; +import path from 'node:path'; +import { Readable } from 'node:stream'; + +import { createLogger } from '@expo/logger'; +import fetch, { Response } from 'node-fetch'; + +import { createDownloadBuildFunction, downloadBuildAsync } from '../downloadBuild'; +import { createGlobalContextMock } from '../../../__tests__/utils/context'; +import { createMockLogger } from '../../../__tests__/utils/logger'; + +// contains a 'TestApp.app/TestApp' file with 'i am executable' content +const APP_TAR_GZ_BUFFER = Buffer.from( + 'H4sIAMK9HGgAA+2SWwrDIBBF/e4qXIEZjY/v7qEbsMHQlASkGujyI40UWvqgEFNK5/xcYUSvHFlFigMARil6ST0nCDlnhnKVhrzWOg2Ai1pwQlX5aoSMIdpTqhKOzWHoXG+f7Evb2vbFOfkd1/wRWLVzIW69Z9b7Qn/hI/8Gkn8pNfpfhVv/eb3wHW/9i3v/yihDKCzc4yF/7r+jdqDu7Jox2n3vNt/ugyAIgqzDBKNW1bQAD' + + Array.from({ length: 442 }, () => 'A') + + '=', + 'base64' +); + +describe('downloadBuild', () => { + it('should handle an archive', async () => { + jest.mocked(fetch).mockResolvedValue({ + ok: true, + body: Readable.from(APP_TAR_GZ_BUFFER), + url: `https://storage.googleapis.com/eas-workflows-production/artifacts/${randomUUID()}/${randomUUID()}/application-${randomUUID()}.tar.gz?X-Goog-Algorithm=GOOG4-RSA-SHA256`, + } as unknown as Response); + + const { artifactPath } = await downloadBuildAsync({ + logger: createLogger({ name: 'test' }), + buildId: randomUUID(), + expoApiServerURL: 'http://api.expo.test', + robotAccessToken: null, + extensions: ['app'], + }); + + expect(artifactPath).toBeDefined(); + expect(await fs.promises.readFile(path.join(artifactPath, 'TestApp'), 'utf8')).toBe( + 'i am executable\n' + ); + }); + + it('should handle a straight-up file', async () => { + jest.mocked(fetch).mockResolvedValue({ + ok: true, + body: Readable.from(Buffer.from('hello')), + url: `https://storage.googleapis.com/eas-workflows-production/artifacts/${randomUUID()}/${randomUUID()}/application-${randomUUID()}.txt?X-Goog-Algorithm=GOOG4-RSA-SHA256`, + } as unknown as Response); + + const { artifactPath } = await downloadBuildAsync({ + logger: createLogger({ name: 'test' }), + buildId: randomUUID(), + expoApiServerURL: 'http://api.expo.test', + robotAccessToken: null, + extensions: ['app'], + }); + + expect(artifactPath).toBeDefined(); + expect(await fs.promises.readFile(artifactPath, 'utf-8')).toBe('hello'); + }); + + it('should throw an error if no matching files are found', async () => { + jest.mocked(fetch).mockResolvedValue({ + ok: true, + body: Readable.from(APP_TAR_GZ_BUFFER), + url: `https://storage.googleapis.com/eas-workflows-production/artifacts/${randomUUID()}/${randomUUID()}/application-${randomUUID()}.tar.gz?X-Goog-Algorithm=GOOG4-RSA-SHA256`, + } as unknown as Response); + + await expect( + downloadBuildAsync({ + logger: createLogger({ name: 'test' }), + buildId: randomUUID(), + expoApiServerURL: 'http://api.expo.test', + robotAccessToken: null, + extensions: ['apk'], + }) + ).rejects.toThrow('No .apk entries found in the archive.'); + }); +}); + +describe('createDownloadBuildFunction', () => { + it('should download a build', async () => { + const downloadBuild = createDownloadBuildFunction(); + const logger = createMockLogger(); + + const buildStep = downloadBuild.createBuildStepFromFunctionCall( + createGlobalContextMock({ + logger, + staticContextContent: { + expoApiServerURL: 'http://api.expo.test', + job: {}, + }, + }), + { + callInputs: { + build_id: randomUUID(), + extensions: ['app'], + }, + } + ); + + jest.mocked(fetch).mockResolvedValue({ + ok: false, + text: () => Promise.resolve('Internal Server Error'), + status: 500, + } as unknown as Response); + + await expect(buildStep.executeAsync()).rejects.toThrow('Internal Server Error'); + }); +}); diff --git a/packages/build-tools/src/steps/functions/__tests__/findAndUploadBuildArtifacts.test.ts b/packages/build-tools/src/steps/functions/__tests__/findAndUploadBuildArtifacts.test.ts new file mode 100644 index 0000000000..460baeedab --- /dev/null +++ b/packages/build-tools/src/steps/functions/__tests__/findAndUploadBuildArtifacts.test.ts @@ -0,0 +1,94 @@ +import { randomUUID } from 'crypto'; + +import { ManagedArtifactType } from '@expo/eas-build-job'; +import { vol } from 'memfs'; + +import { createGlobalContextMock } from '../../../__tests__/utils/context'; +import { createTestIosJob } from '../../../__tests__/utils/job'; +import { createMockLogger } from '../../../__tests__/utils/logger'; +import { BuildContext } from '../../../context'; +import { CustomBuildContext } from '../../../customBuildContext'; +import { createFindAndUploadBuildArtifactsBuildFunction } from '../findAndUploadBuildArtifacts'; + +jest.mock('fs'); + +describe(createFindAndUploadBuildArtifactsBuildFunction, () => { + const contextUploadArtifact = jest.fn(async () => ({ artifactId: randomUUID() })); + const ctx = new BuildContext(createTestIosJob({}), { + env: { + __API_SERVER_URL: 'http://api.expo.test', + }, + logBuffer: { getLogs: () => [], getPhaseLogs: () => [] }, + logger: createMockLogger(), + uploadArtifact: contextUploadArtifact, + workingdir: '', + }); + const customContext = new CustomBuildContext(ctx); + const findAndUploadBuildArtifacts = createFindAndUploadBuildArtifactsBuildFunction(customContext); + + it('throws first error', async () => { + const globalContext = createGlobalContextMock({}); + const buildStep = findAndUploadBuildArtifacts.createBuildStepFromFunctionCall( + globalContext, + {} + ); + + await expect(buildStep.executeAsync()).rejects.toThrow('There are no files matching pattern'); + }); + + it('does not throw error if it uploads artifact', async () => { + const globalContext = createGlobalContextMock({}); + vol.fromJSON( + { + 'ios/build/test.ipa': '', + }, + globalContext.defaultWorkingDirectory + ); + const buildStep = findAndUploadBuildArtifacts.createBuildStepFromFunctionCall( + globalContext, + {} + ); + + await expect(buildStep.executeAsync()).resolves.not.toThrow(); + }); + + it('throws build artifacts error', async () => { + const globalContext = createGlobalContextMock({}); + ctx.job.buildArtifactPaths = ['worker.log']; + vol.fromJSON( + { + 'ios/build/test.ipa': '', + }, + globalContext.defaultWorkingDirectory + ); + const buildStep = findAndUploadBuildArtifacts.createBuildStepFromFunctionCall( + globalContext, + {} + ); + + await expect(buildStep.executeAsync()).rejects.toThrow('No such file or directory worker.log'); + }); + + it('upload build artifacts and throws application archive error', async () => { + const globalContext = createGlobalContextMock({}); + ctx.job.buildArtifactPaths = ['worker.log']; + vol.fromJSON( + { + 'worker.log': '', + }, + globalContext.defaultWorkingDirectory + ); + const buildStep = findAndUploadBuildArtifacts.createBuildStepFromFunctionCall( + globalContext, + {} + ); + await expect(buildStep.executeAsync()).rejects.toThrow('There are no files matching pattern'); + expect(contextUploadArtifact).toHaveBeenCalledWith( + expect.objectContaining({ + artifact: expect.objectContaining({ + type: ManagedArtifactType.BUILD_ARTIFACTS, + }), + }) + ); + }); +}); diff --git a/packages/build-tools/src/steps/functions/__tests__/repack.test.ts b/packages/build-tools/src/steps/functions/__tests__/repack.test.ts new file mode 100644 index 0000000000..2b7e30d747 --- /dev/null +++ b/packages/build-tools/src/steps/functions/__tests__/repack.test.ts @@ -0,0 +1,207 @@ +import path from 'node:path'; + +import { type bunyan } from '@expo/logger'; +import fg from 'fast-glob'; +import { vol } from 'memfs'; + +import { createGlobalContextMock } from '../../../__tests__/utils/context'; +import { createTestAndroidJob, createTestIosJob } from '../../../__tests__/utils/job'; +import IosCredentialsManager from '../../utils/ios/credentials/manager'; +import { + createRepackBuildFunction, + resolveAndroidSigningOptionsAsync, + resolveIosSigningOptionsAsync, +} from '../repack'; +import ProvisioningProfile, { + DistributionType, +} from '../../utils/ios/credentials/provisioningProfile'; + +jest.mock('fs'); +jest.mock('@expo/spawn-async'); +jest.mock('@expo/repack-app'); +jest.mock('resolve-from', () => jest.fn((_, moduleName) => moduleName)); +jest.mock('../../utils/ios/credentials/manager'); + +describe(createRepackBuildFunction, () => { + afterEach(() => { + jest.restoreAllMocks(); + vol.reset(); + }); + + it('should set the output path for successful repack', async () => { + const repack = createRepackBuildFunction(); + const repackStep = repack.createBuildStepFromFunctionCall( + createGlobalContextMock({ + staticContextContent: { + job: {}, + }, + }), + { + callInputs: { + platform: 'ios', + source_app_path: '/path/to/source_app', + output_path: '/path/to/output_app', + }, + } + ); + + await repackStep.executeAsync(); + expect(repackStep.outputById['output_path'].value).toBe('/path/to/output_app'); + }); + + it('should throw for unsupported platforms', async () => { + const repack = createRepackBuildFunction(); + const repackStep = repack.createBuildStepFromFunctionCall( + createGlobalContextMock({ + staticContextContent: { + job: {}, + }, + }), + { + callInputs: { + platform: 'unknown', + source_app_path: '/path/to/source_app', + }, + } + ); + + await expect(repackStep.executeAsync()).rejects.toThrow(/Unsupported platform/); + }); + + it('should cleanup android keystore files after execution', async () => { + const repack = createRepackBuildFunction(); + const repackStep = repack.createBuildStepFromFunctionCall( + createGlobalContextMock({ + staticContextContent: { + job: createTestAndroidJob(), + }, + }), + { + callInputs: { + platform: 'android', + source_app_path: '/path/to/source_app', + }, + } + ); + + await repackStep.executeAsync(); + const tmpDir = path.dirname(repackStep.outputById['output_path'].value as string); + const keystoreFiles = await fg(`${tmpDir}/keystore*`, { onlyFiles: true }); + expect(keystoreFiles.length).toBe(0); + }); +}); + +describe(resolveAndroidSigningOptionsAsync, () => { + afterEach(() => { + jest.restoreAllMocks(); + vol.reset(); + }); + + it('should resolve android signing options', async () => { + const job = createTestAndroidJob(); + const tmpDir = '/tmp'; + vol.mkdirSync(tmpDir, { recursive: true }); + const signingOptions = await resolveAndroidSigningOptionsAsync({ + job, + tmpDir: '/tmp', + }); + + expect(signingOptions).not.toBeNull(); + expect(signingOptions?.keyStorePath).toMatch(/\/tmp\/keystore/); + expect(signingOptions?.keyStorePassword).toEqual( + `pass:${job.secrets?.buildCredentials?.keystore.keystorePassword}` + ); + expect(signingOptions?.keyAlias).toEqual(job.secrets?.buildCredentials?.keystore.keyAlias); + expect(signingOptions?.keyPassword).toEqual( + `pass:${job.secrets?.buildCredentials?.keystore.keyPassword}` + ); + }); + + it('should return undefined if no build credentials are provided', async () => { + // @ts-expect-error: createTestAndroidJob does not support buildCredentials as null + const job = createTestAndroidJob({ buildCredentials: null }); + const signingOptions = await resolveAndroidSigningOptionsAsync({ + job, + tmpDir: '/tmp', + }); + expect(signingOptions).toBeUndefined(); + }); +}); + +describe(resolveIosSigningOptionsAsync, () => { + afterEach(() => { + jest.restoreAllMocks(); + vol.reset(); + }); + + const mockLogger = { + debug: jest.fn(), + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + } as unknown as bunyan; + + it('should resolve ios signing options', async () => { + const job = createTestIosJob(); + const credentialsManagerSpy = jest.spyOn(IosCredentialsManager.prototype, 'prepare'); + const mockProvisioningProfile = new ProvisioningProfile( + Buffer.from('test-profile'), + '/tmp/ios_provisioning_profile', + 'testapp', + 'Test App Certificate' + ); + const provisioningProfileData = { + path: '/tmp/ios_provisioning_profile', + target: 'testapp', + bundleIdentifier: 'com.example.testapp', + teamId: 'TEAMID123', + uuid: 'UUID123', + name: 'Test App Provisioning Profile', + distributionType: DistributionType.AD_HOC, + developerCertificate: Buffer.from('test-developer-certificate'), + certificateCommonName: 'Test App Certificate', + }; + jest.spyOn(mockProvisioningProfile, 'data', 'get').mockReturnValue(provisioningProfileData); + credentialsManagerSpy.mockResolvedValue({ + applicationTargetProvisioningProfile: mockProvisioningProfile, + keychainPath: '/tmp/ios_keychain', + targetProvisioningProfiles: { + testapp: provisioningProfileData, + }, + distributionType: DistributionType.AD_HOC, + teamId: 'TEAMID123', + }); + + const signingOptions = await resolveIosSigningOptionsAsync({ + job, + logger: mockLogger, + }); + + expect(signingOptions).not.toBeNull(); + expect(signingOptions?.keychainPath).toEqual('/tmp/ios_keychain'); + expect(signingOptions?.signingIdentity).toEqual('Test App Certificate'); + expect(signingOptions?.provisioningProfile).toEqual({ + 'com.example.testapp': '/tmp/ios_provisioning_profile', + }); + }); + + it('should return undefined if no build credentials are provided', async () => { + // @ts-expect-error: createTestIosJob does not support buildCredentials as null + const job = createTestIosJob({ buildCredentials: null }); + const signingOptions = await resolveIosSigningOptionsAsync({ + job, + logger: mockLogger, + }); + expect(signingOptions).toBeUndefined(); + }); + + it('should return undefined if the job is for a simulator', async () => { + const job = createTestIosJob(); + job.simulator = true; // Simulate a job for the iOS simulator + const signingOptions = await resolveIosSigningOptionsAsync({ + job, + logger: mockLogger, + }); + expect(signingOptions).toBeUndefined(); + }); +}); diff --git a/packages/build-tools/src/steps/functions/__tests__/sendSlackMessage.test.ts b/packages/build-tools/src/steps/functions/__tests__/sendSlackMessage.test.ts new file mode 100644 index 0000000000..c92ea28d70 --- /dev/null +++ b/packages/build-tools/src/steps/functions/__tests__/sendSlackMessage.test.ts @@ -0,0 +1,371 @@ +import { errors } from '@expo/steps'; +import fetch, { Response } from 'node-fetch'; +import { bunyan } from '@expo/logger'; + +import { createSendSlackMessageFunction } from '../sendSlackMessage'; +import { createGlobalContextMock } from '../../../__tests__/utils/context'; + +jest.mock('@expo/logger'); +jest.mock('node-fetch'); + +describe(createSendSlackMessageFunction, () => { + const fetchMock = jest.mocked(fetch); + const sendSlackMessage = createSendSlackMessageFunction(); + let loggerInfoMock: jest.SpyInstance; + let loggerWarnMock: jest.SpyInstance; + let loggerDebugMock: jest.SpyInstance; + let loggerErrorMock: jest.SpyInstance; + + afterEach(() => { + jest.resetAllMocks(); + }); + + function mockLogger(logger: bunyan): void { + loggerInfoMock = jest.spyOn(logger, 'info'); + loggerWarnMock = jest.spyOn(logger, 'warn'); + loggerDebugMock = jest.spyOn(logger, 'debug'); + loggerErrorMock = jest.spyOn(logger, 'error'); + } + + it('calls the default webhook defined in SLACK_HOOK_URL secret and logs the info messages when successful', async () => { + fetchMock.mockImplementation(() => Promise.resolve({ status: 200, ok: true } as Response)); + const buildStep = sendSlackMessage.createBuildStepFromFunctionCall( + createGlobalContextMock({}), + { + callInputs: { + message: 'Test message', + }, + env: { + SLACK_HOOK_URL: 'https://slack.hook.url', + }, + id: sendSlackMessage.id, + } + ); + mockLogger(buildStep.ctx.logger); + await buildStep.executeAsync(); + expect(fetchMock).toHaveBeenCalledWith('https://slack.hook.url', { + method: 'POST', + body: JSON.stringify({ text: 'Test message' }), + headers: { 'Content-Type': 'application/json' }, + }); + expect(loggerInfoMock).toHaveBeenCalledTimes(4); + expect(loggerInfoMock).toHaveBeenCalledWith('Sending Slack message'); + expect(loggerInfoMock).toHaveBeenCalledWith('Slack message sent successfully'); + expect(loggerWarnMock).not.toHaveBeenCalled(); + }); + + it('calls the webhook when using multiline plain text input', async () => { + fetchMock.mockImplementation(() => Promise.resolve({ status: 200, ok: true } as Response)); + const buildStep = sendSlackMessage.createBuildStepFromFunctionCall( + createGlobalContextMock({}), + { + callInputs: { + message: 'Line 1\nLine 2\n\nLine 3', + }, + env: { + SLACK_HOOK_URL: 'https://slack.hook.url', + }, + id: sendSlackMessage.id, + } + ); + mockLogger(buildStep.ctx.logger); + await buildStep.executeAsync(); + expect(fetchMock).toHaveBeenCalledWith('https://slack.hook.url', { + method: 'POST', + body: '{"text":"Line 1\\nLine 2\\n\\nLine 3"}', + headers: { 'Content-Type': 'application/json' }, + }); + expect(loggerInfoMock).toHaveBeenCalledTimes(4); + expect(loggerInfoMock).toHaveBeenCalledWith('Sending Slack message'); + expect(loggerInfoMock).toHaveBeenCalledWith('Slack message sent successfully'); + expect(loggerWarnMock).not.toHaveBeenCalled(); + }); + + it('calls the webhook when using multiline plain text input with doubly escaped new lines', async () => { + fetchMock.mockImplementation(() => Promise.resolve({ status: 200, ok: true } as Response)); + const buildStep = sendSlackMessage.createBuildStepFromFunctionCall( + createGlobalContextMock({}), + { + callInputs: { + message: 'Line 1\\nLine 2\\n\\nLine 3', + }, + env: { + SLACK_HOOK_URL: 'https://slack.hook.url', + }, + id: sendSlackMessage.id, + } + ); + mockLogger(buildStep.ctx.logger); + await buildStep.executeAsync(); + expect(fetchMock).toHaveBeenCalledWith('https://slack.hook.url', { + method: 'POST', + body: '{"text":"Line 1\\nLine 2\\n\\nLine 3"}', + headers: { 'Content-Type': 'application/json' }, + }); + expect(loggerInfoMock).toHaveBeenCalledTimes(4); + expect(loggerInfoMock).toHaveBeenCalledWith('Sending Slack message'); + expect(loggerInfoMock).toHaveBeenCalledWith('Slack message sent successfully'); + expect(loggerWarnMock).not.toHaveBeenCalled(); + }); + + it('calls the webhook when using the payload input', async () => { + fetchMock.mockImplementation(() => Promise.resolve({ status: 200, ok: true } as Response)); + const buildStep = sendSlackMessage.createBuildStepFromFunctionCall( + createGlobalContextMock({}), + { + callInputs: { + payload: { blocks: [] }, + }, + env: { + SLACK_HOOK_URL: 'https://slack.hook.url', + }, + id: sendSlackMessage.id, + } + ); + mockLogger(buildStep.ctx.logger); + await buildStep.executeAsync(); + expect(fetchMock).toHaveBeenCalledWith('https://slack.hook.url', { + method: 'POST', + body: JSON.stringify({ blocks: [] }), + headers: { 'Content-Type': 'application/json' }, + }); + expect(loggerInfoMock).toHaveBeenCalledTimes(4); + expect(loggerInfoMock).toHaveBeenCalledWith('Sending Slack message'); + expect(loggerInfoMock).toHaveBeenCalledWith('Slack message sent successfully'); + expect(loggerWarnMock).not.toHaveBeenCalled(); + }); + + it('calls the webhook provided as input overwriting SLACK_HOOK_URL secret and logs the info messages when successful', async () => { + fetchMock.mockImplementation(() => Promise.resolve({ status: 200, ok: true } as Response)); + const buildStep = sendSlackMessage.createBuildStepFromFunctionCall( + createGlobalContextMock({}), + { + callInputs: { + message: 'Test message', + slack_hook_url: 'https://another.slack.hook.url', + }, + env: { + SLACK_HOOK_URL: 'https://slack.hook.url', + }, + id: sendSlackMessage.id, + } + ); + mockLogger(buildStep.ctx.logger); + await buildStep.executeAsync(); + expect(fetchMock).toHaveBeenCalledWith('https://another.slack.hook.url', { + method: 'POST', + body: JSON.stringify({ text: 'Test message' }), + headers: { 'Content-Type': 'application/json' }, + }); + expect(loggerInfoMock).toHaveBeenCalledTimes(4); + expect(loggerInfoMock).toHaveBeenCalledWith('Sending Slack message'); + expect(loggerInfoMock).toHaveBeenCalledWith('Slack message sent successfully'); + expect(loggerWarnMock).not.toHaveBeenCalled(); + }); + + it('calls the webhook provided as reference to specific env variable, overwriting SLACK_HOOK_URL secret and logs the info messages when successful', async () => { + fetchMock.mockImplementation(() => Promise.resolve({ status: 200, ok: true } as Response)); + const ctx = createGlobalContextMock(); + ctx.updateEnv({ + ANOTHER_SLACK_HOOK_URL: 'https://another.slack.hook.url', + }); + const buildStep = sendSlackMessage.createBuildStepFromFunctionCall(ctx, { + callInputs: { + message: 'Test message', + slack_hook_url: '${ eas.env.ANOTHER_SLACK_HOOK_URL }', + }, + env: { + SLACK_HOOK_URL: 'https://slack.hook.url', + }, + id: sendSlackMessage.id, + }); + mockLogger(buildStep.ctx.logger); + await buildStep.executeAsync(); + expect(fetchMock).toHaveBeenCalledWith('https://another.slack.hook.url', { + method: 'POST', + body: JSON.stringify({ text: 'Test message' }), + headers: { 'Content-Type': 'application/json' }, + }); + expect(loggerInfoMock).toHaveBeenCalledTimes(4); + expect(loggerInfoMock).toHaveBeenCalledWith('Sending Slack message'); + expect(loggerInfoMock).toHaveBeenCalledWith('Slack message sent successfully'); + expect(loggerWarnMock).not.toHaveBeenCalled(); + }); + + it('calls the webhook provided as reference to specific env variable, overwriting SLACK_HOOK_URL secret and logs the info messages when successful', async () => { + fetchMock.mockImplementation(() => Promise.resolve({ status: 200, ok: true } as Response)); + const ctx = createGlobalContextMock(); + ctx.updateEnv({ + ANOTHER_SLACK_HOOK_URL: 'https://another.slack.hook.url', + }); + const buildStep = sendSlackMessage.createBuildStepFromFunctionCall(ctx, { + callInputs: { + message: 'Test message', + slack_hook_url: '${{ env.ANOTHER_SLACK_HOOK_URL }}', + }, + env: { + SLACK_HOOK_URL: 'https://slack.hook.url', + }, + id: sendSlackMessage.id, + }); + mockLogger(buildStep.ctx.logger); + await buildStep.executeAsync(); + expect(fetchMock).toHaveBeenCalledWith('https://another.slack.hook.url', { + method: 'POST', + body: JSON.stringify({ text: 'Test message' }), + headers: { 'Content-Type': 'application/json' }, + }); + expect(loggerInfoMock).toHaveBeenCalledTimes(4); + expect(loggerInfoMock).toHaveBeenCalledWith('Sending Slack message'); + expect(loggerInfoMock).toHaveBeenCalledWith('Slack message sent successfully'); + expect(loggerWarnMock).not.toHaveBeenCalled(); + }); + + it('does not call the webhook when no url specified', async () => { + fetchMock.mockImplementation(() => Promise.resolve({ status: 200, ok: true } as Response)); + const buildStep = sendSlackMessage.createBuildStepFromFunctionCall( + createGlobalContextMock({}), + { + callInputs: { + message: 'Test message', + }, + env: {}, + id: sendSlackMessage.id, + } + ); + mockLogger(buildStep.ctx.logger); + const expectedError = new errors.BuildStepRuntimeError( + 'Sending Slack message failed - provide input "slack_hook_url" or set "SLACK_HOOK_URL" secret' + ); + await expect(buildStep.executeAsync()).rejects.toThrow(expectedError); + expect(fetchMock).not.toHaveBeenCalled(); + expect(loggerInfoMock).toHaveBeenCalledTimes(1); + expect(loggerWarnMock).toHaveBeenCalledWith( + 'Slack webhook URL not provided - provide input "slack_hook_url" or set "SLACK_HOOK_URL" secret' + ); + }); + + it('does not call the webhook when no message or payload specified', async () => { + fetchMock.mockImplementation(() => Promise.resolve({ status: 200, ok: true } as Response)); + const buildStep = sendSlackMessage.createBuildStepFromFunctionCall( + createGlobalContextMock({}), + { + callInputs: {}, + env: { + SLACK_HOOK_URL: 'https://slack.hook.url', + }, + id: sendSlackMessage.id, + } + ); + mockLogger(buildStep.ctx.logger); + const expectedError = new errors.BuildStepRuntimeError( + `You need to provide either "message" input or "payload" input to specify the Slack message contents` + ); + await expect(buildStep.executeAsync()).rejects.toThrow(expectedError); + expect(fetchMock).not.toHaveBeenCalled(); + expect(loggerInfoMock).toHaveBeenCalledTimes(1); + expect(loggerWarnMock).not.toHaveBeenCalled(); + }); + + it('does not call the webhook when both message and payload are specified', async () => { + fetchMock.mockImplementation(() => Promise.resolve({ status: 200, ok: true } as Response)); + const buildStep = sendSlackMessage.createBuildStepFromFunctionCall( + createGlobalContextMock({}), + { + callInputs: { + message: 'Test message', + payload: { blocks: [] }, + }, + env: { + SLACK_HOOK_URL: 'https://slack.hook.url', + }, + id: sendSlackMessage.id, + } + ); + mockLogger(buildStep.ctx.logger); + const expectedError = new errors.BuildStepRuntimeError( + `You cannot specify both "message" input and "payload" input - choose one for the Slack message contents` + ); + await expect(buildStep.executeAsync()).rejects.toThrow(expectedError); + expect(fetchMock).not.toHaveBeenCalled(); + expect(loggerInfoMock).toHaveBeenCalledTimes(1); + expect(loggerWarnMock).not.toHaveBeenCalled(); + }); + + it('catches error if thrown, logs warning and details in debug, throws new error', async () => { + const thrownError = new Error('Request failed'); + fetchMock.mockImplementation(() => { + throw thrownError; + }); + const buildStep = sendSlackMessage.createBuildStepFromFunctionCall( + createGlobalContextMock({}), + { + callInputs: { + message: 'Test message', + }, + env: { + SLACK_HOOK_URL: 'https://slack.hook.url', + }, + id: sendSlackMessage.id, + } + ); + mockLogger(buildStep.ctx.logger); + const expectedError = new Error( + 'Sending Slack message to webhook url "https://slack.hook.url" failed' + ); + await expect(buildStep.executeAsync()).rejects.toThrow(expectedError); + expect(fetchMock).toHaveBeenCalledWith('https://slack.hook.url', { + method: 'POST', + body: JSON.stringify({ text: 'Test message' }), + headers: { 'Content-Type': 'application/json' }, + }); + expect(loggerInfoMock).toHaveBeenCalledTimes(2); + expect(loggerInfoMock).toHaveBeenCalledWith('Sending Slack message'); + expect(loggerWarnMock).not.toHaveBeenCalled(); + expect(loggerDebugMock).toHaveBeenCalledWith(thrownError); + expect(loggerErrorMock).toHaveBeenCalledWith({ + err: expectedError, + }); + }); + + it.each([ + [400, 'Bad request'], + [500, 'Internal server error'], + ])( + 'handles %s error status code, logs warning and details in debug, throws new error', + async (statusCode, statusText) => { + fetchMock.mockImplementation(() => + Promise.resolve({ status: statusCode, ok: false, statusText } as Response) + ); + const buildStep = sendSlackMessage.createBuildStepFromFunctionCall( + createGlobalContextMock({}), + { + callInputs: { + message: 'Test message', + }, + env: { + SLACK_HOOK_URL: 'https://slack.hook.url', + }, + id: sendSlackMessage.id, + } + ); + mockLogger(buildStep.ctx.logger); + const expectedError = new Error( + `Sending Slack message to webhook url "https://slack.hook.url" failed with status ${statusCode}` + ); + await expect(buildStep.executeAsync()).rejects.toThrow(expectedError); + expect(fetchMock).toHaveBeenCalledWith('https://slack.hook.url', { + method: 'POST', + body: JSON.stringify({ text: 'Test message' }), + headers: { 'Content-Type': 'application/json' }, + }); + expect(loggerInfoMock).toHaveBeenCalledTimes(2); + expect(loggerInfoMock).toHaveBeenCalledWith('Sending Slack message'); + expect(loggerWarnMock).not.toHaveBeenCalled(); + expect(loggerDebugMock).toHaveBeenCalledWith(`${statusCode} - ${statusText}`); + expect(loggerErrorMock).toHaveBeenCalledWith({ + err: expectedError, + }); + } + ); +}); diff --git a/packages/build-tools/src/steps/functions/__tests__/uploadArtifact.test.ts b/packages/build-tools/src/steps/functions/__tests__/uploadArtifact.test.ts new file mode 100644 index 0000000000..cfd97a78ab --- /dev/null +++ b/packages/build-tools/src/steps/functions/__tests__/uploadArtifact.test.ts @@ -0,0 +1,123 @@ +import { randomBytes, randomUUID } from 'crypto'; +import fs from 'fs'; +import path from 'path'; + +import { createGlobalContextMock } from '../../../__tests__/utils/context'; +import { createTestIosJob } from '../../../__tests__/utils/job'; +import { createMockLogger } from '../../../__tests__/utils/logger'; +import { BuildContext } from '../../../context'; +import { CustomBuildContext } from '../../../customBuildContext'; +import { createUploadArtifactBuildFunction } from '../uploadArtifact'; + +describe(createUploadArtifactBuildFunction, () => { + const contextUploadArtifact = jest.fn(async () => ({ artifactId: randomUUID() })); + const ctx = new BuildContext(createTestIosJob({}), { + env: { + __API_SERVER_URL: 'http://api.expo.test', + }, + logBuffer: { getLogs: () => [], getPhaseLogs: () => [] }, + logger: createMockLogger(), + uploadArtifact: contextUploadArtifact, + workingdir: '', + }); + const customContext = new CustomBuildContext(ctx); + const uploadArtifact = createUploadArtifactBuildFunction(customContext); + + it.each(['build-artifact', 'BUILD_ARTIFACTS'])('accepts %s', async (type) => { + const buildStep = uploadArtifact.createBuildStepFromFunctionCall(createGlobalContextMock({}), { + callInputs: { + type, + path: '/', + }, + }); + const typeInput = buildStep.inputs?.find((input) => input.id === 'type')!; + expect(typeInput.isRawValueOneOfAllowedValues()).toBe(true); + }); + + it('accepts `path` argument', async () => { + const buildStep = uploadArtifact.createBuildStepFromFunctionCall(createGlobalContextMock({}), { + callInputs: { + type: 'build-artifact', + path: '/', + }, + }); + await expect(buildStep.executeAsync()).resolves.not.toThrowError(); + }); + + it('accepts multiline `path` argument', async () => { + const globalContext = createGlobalContextMock({}); + const tempDir = globalContext.defaultWorkingDirectory; + + const debugPath = path.join(tempDir, 'Build', 'Products', 'Debug-iphonesimulator'); + const debugArtifactPath = path.join(debugPath, 'release-artifact.app'); + + const releasePath = path.join(tempDir, 'Build', 'Products', 'Release-iphonesimulator'); + const releaseArtifactPath = path.join(releasePath, 'release-artifact.app'); + + const directArtifactPath = path.join(tempDir, 'artifact.ipa'); + + try { + await fs.promises.mkdir(debugPath, { + recursive: true, + }); + await fs.promises.writeFile(debugArtifactPath, new Uint8Array(randomBytes(10))); + + await fs.promises.mkdir(releasePath, { + recursive: true, + }); + await fs.promises.writeFile(releaseArtifactPath, new Uint8Array(randomBytes(10))); + + await fs.promises.writeFile(directArtifactPath, new Uint8Array(randomBytes(10))); + + const buildStep = uploadArtifact.createBuildStepFromFunctionCall(globalContext, { + callInputs: { + type: 'build-artifact', + path: [ + path.join('Build', 'Products', '*simulator', '*.app'), + path.relative(tempDir, directArtifactPath), + ].join('\n'), + }, + }); + + await buildStep.executeAsync(); + + expect(contextUploadArtifact).toHaveBeenCalledWith( + expect.objectContaining({ + artifact: expect.objectContaining({ + paths: expect.arrayContaining([ + debugArtifactPath, + releaseArtifactPath, + directArtifactPath, + ]), + }), + }) + ); + } finally { + for (const path of [directArtifactPath, debugPath, releasePath]) { + await fs.promises.rm(path, { recursive: true, force: true }); + } + } + }); + + it('does not throw for undefined type input', async () => { + const buildStep = uploadArtifact.createBuildStepFromFunctionCall(createGlobalContextMock({}), { + callInputs: { + path: '/', + }, + }); + for (const input of buildStep.inputs ?? []) { + expect(input.isRawValueOneOfAllowedValues()).toBe(true); + } + }); + + it.each(['invalid-value'])('does not accept %s', async (type) => { + const buildStep = uploadArtifact.createBuildStepFromFunctionCall(createGlobalContextMock({}), { + callInputs: { + type, + path: '/', + }, + }); + const typeInput = buildStep.inputs?.find((input) => input.id === 'type')!; + expect(typeInput.isRawValueOneOfAllowedValues()).toBe(false); + }); +}); diff --git a/packages/build-tools/src/steps/functions/calculateEASUpdateRuntimeVersion.ts b/packages/build-tools/src/steps/functions/calculateEASUpdateRuntimeVersion.ts new file mode 100644 index 0000000000..1a9ca610a8 --- /dev/null +++ b/packages/build-tools/src/steps/functions/calculateEASUpdateRuntimeVersion.ts @@ -0,0 +1,87 @@ +import { Platform, Workflow } from '@expo/eas-build-job'; +import { + BuildFunction, + BuildStepInput, + BuildStepInputValueTypeName, + BuildStepOutput, +} from '@expo/steps'; + +import { resolveRuntimeVersionForExpoUpdatesIfConfiguredAsync } from '../../utils/expoUpdates'; +import { readAppConfig } from '../../utils/appConfig'; + +export function calculateEASUpdateRuntimeVersionFunction(): BuildFunction { + return new BuildFunction({ + namespace: 'eas', + id: 'calculate_eas_update_runtime_version', + name: 'Calculate EAS Update Runtime Version', + inputProviders: [ + BuildStepInput.createProvider({ + id: 'platform', + required: false, + allowedValueTypeName: BuildStepInputValueTypeName.STRING, + }), + BuildStepInput.createProvider({ + id: 'workflow', + required: false, + allowedValueTypeName: BuildStepInputValueTypeName.STRING, + }), + ], + outputProviders: [ + BuildStepOutput.createProvider({ + id: 'resolved_eas_update_runtime_version', + required: false, + }), + ], + fn: async (stepCtx, { env, inputs, outputs }) => { + const appConfig = readAppConfig({ + projectDir: stepCtx.workingDirectory, + env: Object.keys(env).reduce( + (acc, key) => { + acc[key] = env[key] ?? ''; + return acc; + }, + {} as Record + ), + logger: stepCtx.logger, + sdkVersion: stepCtx.global.staticContext.metadata?.sdkVersion, + }).exp; + + const platform = + (inputs.platform.value as Platform) ?? stepCtx.global.staticContext.job.platform; + const workflow = (inputs.workflow.value as Workflow) ?? stepCtx.global.staticContext.job.type; + + if (![Platform.ANDROID, Platform.IOS].includes(platform)) { + throw new Error( + `Unsupported platform: ${platform}. Platform must be "${Platform.ANDROID}" or "${Platform.IOS}"` + ); + } + + if (![Workflow.GENERIC, Workflow.MANAGED].includes(workflow)) { + if (workflow === Workflow.UNKNOWN) { + throw new Error( + `Detected ${Workflow.UNKNOWN} workflow. Please make sure to run the eas/resolve_build_config step before running this step.` + ); + } + throw new Error( + `Unsupported workflow: ${workflow}. Workflow must be "${Workflow.GENERIC}" or "${Workflow.MANAGED}"` + ); + } + + const resolvedRuntimeVersion = await resolveRuntimeVersionForExpoUpdatesIfConfiguredAsync({ + cwd: stepCtx.workingDirectory, + logger: stepCtx.logger, + appConfig, + platform, + workflow, + env, + }); + if (resolvedRuntimeVersion) { + outputs.resolved_eas_update_runtime_version.set( + resolvedRuntimeVersion.runtimeVersion ?? undefined + ); + } else { + stepCtx.logger.info('Skipped because EAS Update is not configured'); + } + }, + }); +} diff --git a/packages/build-tools/src/steps/functions/checkout.ts b/packages/build-tools/src/steps/functions/checkout.ts new file mode 100644 index 0000000000..f2a4987597 --- /dev/null +++ b/packages/build-tools/src/steps/functions/checkout.ts @@ -0,0 +1,25 @@ +import { BuildFunction } from '@expo/steps'; +import fs from 'fs-extra'; + +export function createCheckoutBuildFunction(): BuildFunction { + return new BuildFunction({ + namespace: 'eas', + id: 'checkout', + name: 'Checkout', + fn: async (stepsCtx) => { + if (stepsCtx.global.wasCheckedOut()) { + stepsCtx.logger.info('Project directory is already checked out'); + return; + } + stepsCtx.logger.info('Checking out project directory'); + await fs.move( + stepsCtx.global.projectSourceDirectory, + stepsCtx.global.projectTargetDirectory, + { + overwrite: true, + } + ); + stepsCtx.global.markAsCheckedOut(stepsCtx.logger); + }, + }); +} diff --git a/packages/build-tools/src/steps/functions/configureAndroidVersion.ts b/packages/build-tools/src/steps/functions/configureAndroidVersion.ts new file mode 100644 index 0000000000..d23223e9a2 --- /dev/null +++ b/packages/build-tools/src/steps/functions/configureAndroidVersion.ts @@ -0,0 +1,45 @@ +import assert from 'assert'; + +import { BuildFunction, BuildStepInput, BuildStepInputValueTypeName } from '@expo/steps'; +import semver from 'semver'; +import { Android } from '@expo/eas-build-job'; + +import { injectConfigureVersionGradleConfig } from '../utils/android/gradleConfig'; + +export function configureAndroidVersionFunction(): BuildFunction { + return new BuildFunction({ + namespace: 'eas', + id: 'configure_android_version', + name: 'Configure Android version', + inputProviders: [ + BuildStepInput.createProvider({ + id: 'version_name', + required: false, + allowedValueTypeName: BuildStepInputValueTypeName.STRING, + }), + BuildStepInput.createProvider({ + id: 'version_code', + required: false, + allowedValueTypeName: BuildStepInputValueTypeName.STRING, + }), + ], + fn: async (stepCtx, { inputs }) => { + assert(stepCtx.global.staticContext.job, 'Job is not defined'); + const job = stepCtx.global.staticContext.job as Android.Job; + + const versionCode = + (inputs.version_code.value as string | undefined) ?? job.version?.versionCode; + const versionName = + (inputs.version_name.value as string | undefined) ?? job.version?.versionName; + if (versionName && !semver.valid(versionName)) { + throw new Error( + `Version name provided by the "version_name" input is not a valid semver version: ${versionName}` + ); + } + await injectConfigureVersionGradleConfig(stepCtx.logger, stepCtx.workingDirectory, { + versionCode, + versionName, + }); + }, + }); +} diff --git a/packages/build-tools/src/steps/functions/configureEASUpdateIfInstalled.ts b/packages/build-tools/src/steps/functions/configureEASUpdateIfInstalled.ts new file mode 100644 index 0000000000..ca2a8b86b9 --- /dev/null +++ b/packages/build-tools/src/steps/functions/configureEASUpdateIfInstalled.ts @@ -0,0 +1,100 @@ +import assert from 'assert'; + +import { BuildFunction, BuildStepInput, BuildStepInputValueTypeName } from '@expo/steps'; +import { Metadata } from '@expo/eas-build-job'; +import semver from 'semver'; + +import { configureEASUpdateAsync } from '../utils/expoUpdates'; +import { readAppConfig } from '../../utils/appConfig'; +import getExpoUpdatesPackageVersionIfInstalledAsync from '../../utils/getExpoUpdatesPackageVersionIfInstalledAsync'; + +export function configureEASUpdateIfInstalledFunction(): BuildFunction { + return new BuildFunction({ + namespace: 'eas', + id: 'configure_eas_update', + name: 'Configure EAS Update', + inputProviders: [ + BuildStepInput.createProvider({ + id: 'runtime_version', + required: false, + allowedValueTypeName: BuildStepInputValueTypeName.STRING, + }), + BuildStepInput.createProvider({ + id: 'channel', + required: false, + allowedValueTypeName: BuildStepInputValueTypeName.STRING, + }), + BuildStepInput.createProvider({ + id: 'throw_if_not_configured', + required: false, + defaultValue: true, + allowedValueTypeName: BuildStepInputValueTypeName.BOOLEAN, + }), + BuildStepInput.createProvider({ + id: 'resolved_eas_update_runtime_version', + required: false, + allowedValueTypeName: BuildStepInputValueTypeName.STRING, + }), + ], + fn: async (stepCtx, { env, inputs }) => { + assert(stepCtx.global.staticContext.job, 'Job is not defined'); + const job = stepCtx.global.staticContext.job; + assert(job.platform, 'Configuring EAS Update in generic jobs is not supported.'); + const metadata = stepCtx.global.staticContext.metadata as Metadata | undefined; + + const appConfig = readAppConfig({ + projectDir: stepCtx.workingDirectory, + env: Object.keys(env).reduce( + (acc, key) => { + acc[key] = env[key] ?? ''; + return acc; + }, + {} as Record + ), + logger: stepCtx.logger, + sdkVersion: metadata?.sdkVersion, + }).exp; + + const channelInput = inputs.channel.value as string | undefined; + const runtimeVersionInput = inputs.runtime_version.value as string | undefined; + const resolvedRuntimeVersionInput = inputs.resolved_eas_update_runtime_version.value as + | string + | undefined; + const throwIfNotConfigured = inputs.throw_if_not_configured.value as boolean; + if (runtimeVersionInput && !semver.valid(runtimeVersionInput)) { + throw new Error( + `Runtime version provided by the "runtime_version" input is not a valid semver version: ${runtimeVersionInput}` + ); + } + + const expoUpdatesPackageVersion = await getExpoUpdatesPackageVersionIfInstalledAsync( + stepCtx.workingDirectory, + stepCtx.logger + ); + if (expoUpdatesPackageVersion === null) { + if (throwIfNotConfigured) { + throw new Error( + 'Cannot configure EAS Update because the expo-updates package is not installed.' + ); + } + stepCtx.logger.warn( + 'Cannot configure EAS Update because the expo-updates package is not installed.' + ); + return; + } + + await configureEASUpdateAsync({ + job, + workingDirectory: stepCtx.workingDirectory, + logger: stepCtx.logger, + appConfig, + inputs: { + runtimeVersion: runtimeVersionInput, + channel: channelInput, + resolvedRuntimeVersion: resolvedRuntimeVersionInput, + }, + metadata: stepCtx.global.staticContext.metadata, + }); + }, + }); +} diff --git a/packages/build-tools/src/steps/functions/configureIosCredentials.ts b/packages/build-tools/src/steps/functions/configureIosCredentials.ts new file mode 100644 index 0000000000..e82cdb30fb --- /dev/null +++ b/packages/build-tools/src/steps/functions/configureIosCredentials.ts @@ -0,0 +1,57 @@ +import assert from 'assert'; + +import { BuildFunction, BuildStepInput, BuildStepInputValueTypeName } from '@expo/steps'; +import { Ios } from '@expo/eas-build-job'; + +import IosCredentialsManager from '../utils/ios/credentials/manager'; +import { IosBuildCredentialsSchema } from '../utils/ios/credentials/credentials'; +import { configureCredentialsAsync } from '../utils/ios/configure'; +import { resolveBuildConfiguration } from '../utils/ios/resolve'; + +export function configureIosCredentialsFunction(): BuildFunction { + return new BuildFunction({ + namespace: 'eas', + id: 'configure_ios_credentials', + name: 'Configure iOS credentials', + inputProviders: [ + BuildStepInput.createProvider({ + id: 'credentials', + required: true, + allowedValueTypeName: BuildStepInputValueTypeName.JSON, + defaultValue: '${ eas.job.secrets.buildCredentials }', + }), + BuildStepInput.createProvider({ + id: 'build_configuration', + required: false, + allowedValueTypeName: BuildStepInputValueTypeName.STRING, + }), + ], + fn: async (stepCtx, { inputs }) => { + const rawCredentialsInput = inputs.credentials.value as Record; + const { value, error } = IosBuildCredentialsSchema.validate(rawCredentialsInput, { + stripUnknown: true, + convert: true, + abortEarly: false, + }); + if (error) { + throw error; + } + + const credentialsManager = new IosCredentialsManager(value); + const credentials = await credentialsManager.prepare(stepCtx.logger); + + assert(stepCtx.global.staticContext.job, 'Job is not defined'); + const job = stepCtx.global.staticContext.job as Ios.Job; + + await configureCredentialsAsync(stepCtx.logger, stepCtx.workingDirectory, { + credentials, + buildConfiguration: resolveBuildConfiguration( + job, + inputs.build_configuration.value as string | undefined + ), + }); + + stepCtx.logger.info('Successfully configured iOS credentials'); + }, + }); +} diff --git a/packages/build-tools/src/steps/functions/configureIosVersion.ts b/packages/build-tools/src/steps/functions/configureIosVersion.ts new file mode 100644 index 0000000000..8b893ebc84 --- /dev/null +++ b/packages/build-tools/src/steps/functions/configureIosVersion.ts @@ -0,0 +1,99 @@ +import assert from 'assert'; + +import { BuildFunction, BuildStepInput, BuildStepInputValueTypeName } from '@expo/steps'; +import { Ios } from '@expo/eas-build-job'; +import semver from 'semver'; + +import { IosBuildCredentialsSchema } from '../utils/ios/credentials/credentials'; +import IosCredentialsManager from '../utils/ios/credentials/manager'; +import { updateVersionsAsync } from '../utils/ios/configure'; +import { resolveBuildConfiguration } from '../utils/ios/resolve'; + +export function configureIosVersionFunction(): BuildFunction { + return new BuildFunction({ + namespace: 'eas', + id: 'configure_ios_version', + name: 'Configure iOS version', + inputProviders: [ + BuildStepInput.createProvider({ + id: 'credentials', + required: true, + allowedValueTypeName: BuildStepInputValueTypeName.JSON, + defaultValue: '${ eas.job.secrets.buildCredentials }', + }), + BuildStepInput.createProvider({ + id: 'build_configuration', + required: false, + allowedValueTypeName: BuildStepInputValueTypeName.STRING, + }), + BuildStepInput.createProvider({ + id: 'build_number', + required: false, + allowedValueTypeName: BuildStepInputValueTypeName.STRING, + }), + BuildStepInput.createProvider({ + id: 'app_version', + required: false, + allowedValueTypeName: BuildStepInputValueTypeName.STRING, + }), + ], + fn: async (stepCtx, { inputs }) => { + const rawCredentialsInput = inputs.credentials.value as Record; + const { value, error } = IosBuildCredentialsSchema.validate(rawCredentialsInput, { + stripUnknown: true, + convert: true, + abortEarly: false, + }); + if (error) { + throw error; + } + + const credentialsManager = new IosCredentialsManager(value); + const credentials = await credentialsManager.prepare(stepCtx.logger); + + assert(stepCtx.global.staticContext.job, 'Job is not defined'); + const job = stepCtx.global.staticContext.job as Ios.Job; + + const buildNumber = + (inputs.build_number.value as string | undefined) ?? job.version?.buildNumber; + const appVersion = + (inputs.app_version.value as string | undefined) ?? job.version?.appVersion; + if (appVersion && !semver.valid(appVersion)) { + throw new Error( + `App verrsion provided by the "app_version" input is not a valid semver version: ${appVersion}` + ); + } + + if (!buildNumber && !appVersion) { + stepCtx.logger.info( + 'No build number or app version provided. Skipping the step to configure iOS version.' + ); + return; + } + + stepCtx.logger.info('Setting iOS version...'); + if (buildNumber) { + stepCtx.logger.info(`Build number: ${buildNumber}`); + } + if (appVersion) { + stepCtx.logger.info(`App version: ${appVersion}`); + } + + await updateVersionsAsync( + stepCtx.logger, + stepCtx.workingDirectory, + { + buildNumber, + appVersion, + }, + { + targetNames: Object.keys(credentials.targetProvisioningProfiles), + buildConfiguration: resolveBuildConfiguration( + job, + inputs.build_configuration.value as string | undefined + ), + } + ); + }, + }); +} diff --git a/packages/build-tools/src/steps/functions/createSubmissionEntity.ts b/packages/build-tools/src/steps/functions/createSubmissionEntity.ts new file mode 100644 index 0000000000..4f9a4c109f --- /dev/null +++ b/packages/build-tools/src/steps/functions/createSubmissionEntity.ts @@ -0,0 +1,133 @@ +import { BuildFunction, BuildStepInput, BuildStepInputValueTypeName } from '@expo/steps'; +import { asyncResult } from '@expo/results'; + +import { retryOnDNSFailure } from '../../utils/retryOnDNSFailure'; + +export function createSubmissionEntityFunction(): BuildFunction { + return new BuildFunction({ + namespace: 'eas', + id: 'create_submission_entity', + name: 'Create Submission Entity', + inputProviders: [ + BuildStepInput.createProvider({ + id: 'build_id', + allowedValueTypeName: BuildStepInputValueTypeName.STRING, + required: true, + }), + + // AndroidSubmissionConfig + BuildStepInput.createProvider({ + id: 'track', + allowedValueTypeName: BuildStepInputValueTypeName.STRING, + required: false, + }), + BuildStepInput.createProvider({ + id: 'release_status', + allowedValueTypeName: BuildStepInputValueTypeName.STRING, + required: false, + }), + BuildStepInput.createProvider({ + id: 'rollout', + allowedValueTypeName: BuildStepInputValueTypeName.NUMBER, + required: false, + }), + BuildStepInput.createProvider({ + id: 'changes_not_sent_for_review', + allowedValueTypeName: BuildStepInputValueTypeName.BOOLEAN, + required: false, + }), + + // IosSubmissionConfig + BuildStepInput.createProvider({ + id: 'apple_id_username', + allowedValueTypeName: BuildStepInputValueTypeName.STRING, + required: false, + }), + BuildStepInput.createProvider({ + id: 'asc_app_identifier', + allowedValueTypeName: BuildStepInputValueTypeName.STRING, + required: false, + }), + ], + fn: async (stepsCtx, { inputs }) => { + const robotAccessToken = stepsCtx.global.staticContext.job.secrets?.robotAccessToken; + if (!robotAccessToken) { + stepsCtx.logger.error('Failed to create submission entity: no robot access token found'); + return; + } + + const buildId = inputs.build_id.value; + if (!buildId) { + stepsCtx.logger.error('Failed to create submission entity: no build ID provided'); + return; + } + + const workflowJobId = stepsCtx.global.env.__WORKFLOW_JOB_ID; + if (!workflowJobId) { + stepsCtx.logger.error('Failed to create submission entity: no workflow job ID found'); + return; + } + + // This is supposed to provide fallback for `''` -> `undefined`. + // We _not_ want to use nullish coalescing. + /* eslint-disable @typescript-eslint/prefer-nullish-coalescing */ + const track = inputs.track.value || undefined; + const releaseStatus = inputs.release_status.value || undefined; + const rollout = inputs.rollout.value || undefined; + const changesNotSentForReview = inputs.changes_not_sent_for_review.value || undefined; + + const appleIdUsername = inputs.apple_id_username.value || undefined; + const ascAppIdentifier = inputs.asc_app_identifier.value || undefined; + /* eslint-enable @typescript-eslint/prefer-nullish-coalescing */ + + try { + const response = await retryOnDNSFailure(fetch)( + new URL('/v2/app-store-submissions/', stepsCtx.global.staticContext.expoApiServerURL), + { + method: 'POST', + headers: { + Authorization: `Bearer ${robotAccessToken}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + workflowJobId, + turtleBuildId: buildId, + // We can pass mixed object here because the configs are disjoint. + config: { + // AndroidSubmissionConfig + track, + releaseStatus, + rollout, + changesNotSentForReview, + + // IosSubmissionConfig + appleIdUsername, + ascAppIdentifier, + }, + }), + } + ); + + if (!response.ok) { + const textResult = await asyncResult(response.text()); + throw new Error( + `Unexpected response from server (${response.status}): ${textResult.value}` + ); + } + + const jsonResult = await asyncResult(response.json()); + if (!jsonResult.ok) { + stepsCtx.logger.warn( + `Submission created. Failed to parse response. ${jsonResult.reason}` + ); + return; + } + + const data = jsonResult.value.data; + stepsCtx.logger.info(`Submission created:\n ID: ${data.id}\n URL: ${data.url}`); + } catch (e) { + stepsCtx.logger.error(`Failed to create submission entity. ${e}`); + } + }, + }); +} diff --git a/packages/build-tools/src/steps/functions/downloadArtifact.ts b/packages/build-tools/src/steps/functions/downloadArtifact.ts new file mode 100644 index 0000000000..4f3b12e82b --- /dev/null +++ b/packages/build-tools/src/steps/functions/downloadArtifact.ts @@ -0,0 +1,158 @@ +import path from 'node:path'; +import fs from 'node:fs'; +import os from 'node:os'; +import stream from 'stream'; +import { promisify } from 'util'; + +import { + BuildFunction, + BuildStepInput, + BuildStepInputValueTypeName, + BuildStepOutput, +} from '@expo/steps'; +import { asyncResult } from '@expo/results'; +import fetch from 'node-fetch'; +import { z } from 'zod'; +import { bunyan } from '@expo/logger'; +import { UserFacingError } from '@expo/eas-build-job/dist/errors'; + +import { retryOnDNSFailure } from '../../utils/retryOnDNSFailure'; +import { formatBytes } from '../../utils/artifacts'; +import { decompressTarAsync, isFileTarGzAsync } from '../../utils/files'; + +const streamPipeline = promisify(stream.pipeline); + +export function createDownloadArtifactFunction(): BuildFunction { + return new BuildFunction({ + namespace: 'eas', + id: 'download_artifact', + name: 'Download artifact', + inputProviders: [ + BuildStepInput.createProvider({ + id: 'name', + required: false, + allowedValueTypeName: BuildStepInputValueTypeName.STRING, + }), + BuildStepInput.createProvider({ + id: 'artifact_id', + required: false, + allowedValueTypeName: BuildStepInputValueTypeName.STRING, + }), + ], + outputProviders: [ + BuildStepOutput.createProvider({ + id: 'artifact_path', + required: true, + }), + ], + fn: async (stepsCtx, { inputs, outputs }) => { + const params = z + .union([z.object({ artifactId: z.string() }), z.object({ name: z.string() })]) + .parse({ + artifactId: inputs.artifact_id.value, + name: inputs.name.value, + }); + + const interpolationContext = stepsCtx.global.getInterpolationContext(); + + if (!('workflow' in interpolationContext)) { + throw new UserFacingError( + 'EAS_DOWNLOAD_ARTIFACT_NO_WORKFLOW', + 'No workflow found in the interpolation context.' + ); + } + + const robotAccessToken = stepsCtx.global.staticContext.job.secrets?.robotAccessToken; + if (!robotAccessToken) { + throw new UserFacingError( + 'EAS_DOWNLOAD_ARTIFACT_NO_ROBOT_ACCESS_TOKEN', + 'No robot access token found in the job secrets.' + ); + } + + const workflowRunId = interpolationContext.workflow.id; + const { logger } = stepsCtx; + + if ('artifactId' in params) { + logger.info(`Downloading artifact with ID "${params.artifactId}"...`); + } else { + logger.info(`Downloading artifact with name "${params.name}"...`); + } + + const { artifactPath } = await downloadArtifactAsync({ + logger, + workflowRunId, + expoApiServerURL: stepsCtx.global.staticContext.expoApiServerURL, + robotAccessToken, + params, + }); + + outputs.artifact_path.set(artifactPath); + }, + }); +} + +export async function downloadArtifactAsync({ + logger, + workflowRunId, + expoApiServerURL, + robotAccessToken, + params, +}: { + logger: bunyan; + workflowRunId: string; + expoApiServerURL: string; + robotAccessToken: string; + params: { artifactId: string } | { name: string }; +}): Promise<{ artifactPath: string }> { + const downloadDestinationDirectory = await fs.promises.mkdtemp( + path.join(os.tmpdir(), 'download_artifact-') + ); + + const url = new URL(`/v2/workflows/${workflowRunId}/download-artifact`, expoApiServerURL); + + if ('artifactId' in params) { + url.searchParams.set('artifactId', params.artifactId); + } else { + url.searchParams.set('name', params.name); + } + + const response = await retryOnDNSFailure(fetch)(url, { + headers: robotAccessToken ? { Authorization: `Bearer ${robotAccessToken}` } : undefined, + }); + + if (!response.ok) { + const textResult = await asyncResult(response.text()); + throw new Error(`Unexpected response from server (${response.status}): ${textResult.value}`); + } + + // URL may contain percent-encoded characters, e.g. my%20file.apk + // this replaces all non-alphanumeric characters (excluding dot) with underscore + const archiveFilename = path + .basename(new URL(response.url).pathname) + .replace(/([^a-z0-9.-]+)/gi, '_'); + const archivePath = path.join(downloadDestinationDirectory, archiveFilename); + + await streamPipeline(response.body, fs.createWriteStream(archivePath)); + + const { size } = await fs.promises.stat(archivePath); + + logger.info(`Downloaded ${archivePath} (${formatBytes(size)} bytes).`); + + const isFileATarGzArchive = await isFileTarGzAsync(archivePath); + + if (!isFileATarGzArchive) { + logger.info(`Artifact is not a .tar.gz archive, skipping decompression and validation.`); + return { artifactPath: archivePath }; + } + + const extractionDirectory = await fs.promises.mkdtemp( + path.join(os.tmpdir(), 'download_artifact-extracted-') + ); + await decompressTarAsync({ + archivePath, + destinationDirectory: extractionDirectory, + }); + + return { artifactPath: extractionDirectory }; +} diff --git a/packages/build-tools/src/steps/functions/downloadBuild.ts b/packages/build-tools/src/steps/functions/downloadBuild.ts new file mode 100644 index 0000000000..8ba41a000c --- /dev/null +++ b/packages/build-tools/src/steps/functions/downloadBuild.ts @@ -0,0 +1,148 @@ +import path from 'node:path'; +import fs from 'node:fs'; +import os from 'node:os'; +import stream from 'stream'; +import { promisify } from 'util'; + +import { + BuildFunction, + BuildStepInput, + BuildStepInputValueTypeName, + BuildStepOutput, +} from '@expo/steps'; +import { asyncResult } from '@expo/results'; +import fetch from 'node-fetch'; +import { glob } from 'fast-glob'; +import { z } from 'zod'; +import { bunyan } from '@expo/logger'; +import { UserFacingError } from '@expo/eas-build-job/dist/errors'; + +import { retryOnDNSFailure } from '../../utils/retryOnDNSFailure'; +import { formatBytes } from '../../utils/artifacts'; +import { decompressTarAsync, isFileTarGzAsync } from '../../utils/files'; +import { pluralize } from '../../utils/strings'; + +const streamPipeline = promisify(stream.pipeline); + +export function createDownloadBuildFunction(): BuildFunction { + return new BuildFunction({ + namespace: 'eas', + id: 'download_build', + name: 'Download build', + inputProviders: [ + BuildStepInput.createProvider({ + id: 'build_id', + required: true, + allowedValueTypeName: BuildStepInputValueTypeName.STRING, + }), + BuildStepInput.createProvider({ + id: 'extensions', + required: false, + allowedValueTypeName: BuildStepInputValueTypeName.JSON, + defaultValue: ['apk', 'aab', 'ipa', 'app'], + }), + ], + outputProviders: [ + BuildStepOutput.createProvider({ + id: 'artifact_path', + required: true, + }), + ], + fn: async (stepsCtx, { inputs, outputs }) => { + const { logger } = stepsCtx; + + const extensions = z.array(z.string()).parse(inputs.extensions.value); + logger.info(`Expected extensions: [${extensions.join(', ')}]`); + const buildId = z.string().uuid().parse(inputs.build_id.value); + logger.info(`Downloading build ${buildId}...`); + + const { artifactPath } = await downloadBuildAsync({ + logger, + buildId, + expoApiServerURL: stepsCtx.global.staticContext.expoApiServerURL, + robotAccessToken: stepsCtx.global.staticContext.job.secrets?.robotAccessToken ?? null, + extensions, + }); + + outputs.artifact_path.set(artifactPath); + }, + }); +} + +export async function downloadBuildAsync({ + logger, + buildId, + expoApiServerURL, + robotAccessToken, + extensions, +}: { + logger: bunyan; + buildId: string; + expoApiServerURL: string; + robotAccessToken: string | null; + extensions: string[]; +}): Promise<{ artifactPath: string }> { + const downloadDestinationDirectory = await fs.promises.mkdtemp( + path.join(os.tmpdir(), 'download_build-downloaded-') + ); + + const response = await retryOnDNSFailure(fetch)( + new URL(`/v2/artifacts/eas/${buildId}`, expoApiServerURL), + { + headers: robotAccessToken ? { Authorization: `Bearer ${robotAccessToken}` } : undefined, + } + ); + + if (!response.ok) { + const textResult = await asyncResult(response.text()); + throw new Error(`Unexpected response from server (${response.status}): ${textResult.value}`); + } + + // URL may contain percent-encoded characters, e.g. my%20file.apk + // this replaces all non-alphanumeric characters (excluding dot) with underscore + const archiveFilename = path + .basename(new URL(response.url).pathname) + .replace(/([^a-z0-9.-]+)/gi, '_'); + const archivePath = path.join(downloadDestinationDirectory, archiveFilename); + + await streamPipeline(response.body, fs.createWriteStream(archivePath)); + + const { size } = await fs.promises.stat(archivePath); + + logger.info(`Downloaded ${archivePath} (${formatBytes(size)} bytes).`); + + const isFileATarGzArchive = await isFileTarGzAsync(archivePath); + + if (!isFileATarGzArchive) { + logger.info(`Artifact is not a .tar.gz archive, skipping decompression and validation.`); + return { artifactPath: archivePath }; + } + + const extractionDirectory = await fs.promises.mkdtemp( + path.join(os.tmpdir(), 'download_build-extracted-') + ); + await decompressTarAsync({ + archivePath, + destinationDirectory: extractionDirectory, + }); + + const matchingFiles = await glob(`**/*.(${extensions.join('|')})`, { + absolute: true, + cwd: extractionDirectory, + onlyFiles: false, + onlyDirectories: false, + }); + + if (matchingFiles.length === 0) { + throw new UserFacingError( + 'EAS_DOWNLOAD_BUILD_NO_MATCHING_FILES', + `No ${extensions.map((ext) => `.${ext}`).join(', ')} entries found in the archive.` + ); + } + + logger.info( + `Found ${matchingFiles.length} matching ${pluralize(matchingFiles.length, 'entry')}:\n${matchingFiles.map((f) => `- ${path.relative(extractionDirectory, f)}`).join('\n')}` + ); + + return { artifactPath: matchingFiles[0] }; +} diff --git a/packages/build-tools/src/steps/functions/eagerBundle.ts b/packages/build-tools/src/steps/functions/eagerBundle.ts new file mode 100644 index 0000000000..06a1c8dad8 --- /dev/null +++ b/packages/build-tools/src/steps/functions/eagerBundle.ts @@ -0,0 +1,53 @@ +import { BuildFunction, BuildStepInput, BuildStepInputValueTypeName } from '@expo/steps'; +import semver from 'semver'; + +import { eagerBundleAsync } from '../../common/eagerBundle'; +import { resolvePackageManager } from '../../utils/packageManager'; + +export function eagerBundleBuildFunction(): BuildFunction { + return new BuildFunction({ + namespace: 'eas', + id: 'eager_bundle', + name: 'Bundle JavaScript', + inputProviders: [ + BuildStepInput.createProvider({ + id: 'resolved_eas_update_runtime_version', + required: false, + allowedValueTypeName: BuildStepInputValueTypeName.STRING, + }), + ], + fn: async (stepsCtx, { env, inputs }) => { + const job = stepsCtx.global.staticContext.job; + if (!job.platform) { + throw new Error('Custom build job must have platform set'); + } + if ( + stepsCtx.global.staticContext.metadata?.sdkVersion && + !semver.satisfies(stepsCtx.global.staticContext.metadata?.sdkVersion, '>=52') + ) { + throw new Error('Eager bundle is not supported for SDK version < 52'); + } + + const packageManager = resolvePackageManager(stepsCtx.workingDirectory); + const resolvedEASUpdateRuntimeVersion = inputs.resolved_eas_update_runtime_version.value as + | string + | undefined; + + await eagerBundleAsync({ + platform: job.platform, + workingDir: stepsCtx.workingDirectory, + logger: stepsCtx.logger, + env: { + ...env, + ...(resolvedEASUpdateRuntimeVersion + ? { + EXPO_UPDATES_FINGERPRINT_OVERRIDE: resolvedEASUpdateRuntimeVersion, + EXPO_UPDATES_WORKFLOW_OVERRIDE: stepsCtx.global.staticContext.job.type, + } + : null), + }, + packageManager, + }); + }, + }); +} diff --git a/packages/build-tools/src/steps/functions/findAndUploadBuildArtifacts.ts b/packages/build-tools/src/steps/functions/findAndUploadBuildArtifacts.ts new file mode 100644 index 0000000000..6abb02d07f --- /dev/null +++ b/packages/build-tools/src/steps/functions/findAndUploadBuildArtifacts.ts @@ -0,0 +1,155 @@ +import { ManagedArtifactType, Ios, Platform, BuildJob } from '@expo/eas-build-job'; +import { BuildFunction, BuildStepContext } from '@expo/steps'; + +import { findArtifacts } from '../../utils/artifacts'; +import { findXcodeBuildLogsPathAsync } from '../../ios/xcodeBuildLogs'; +import { CustomBuildContext } from '../../customBuildContext'; + +export function createFindAndUploadBuildArtifactsBuildFunction( + ctx: CustomBuildContext +): BuildFunction { + return new BuildFunction({ + namespace: 'eas', + id: 'find_and_upload_build_artifacts', + name: 'Find and upload build artifacts', + fn: async (stepCtx) => { + // We want each upload to print logs on its own + // and we don't want to interleave logs from different uploads + // so we execute uploads consecutively. + // Both application archive and build artifact uploads errors + // are throwing. We count the former as more important, + // so we save its for final throw. + + let firstError: any = null; + + try { + await uploadApplicationArchivesAsync({ ctx, stepCtx }); + } catch (err: unknown) { + stepCtx.logger.error(`Failed to upload application archives.`, err); + firstError ||= err; + } + + try { + await uploadBuildArtifacts({ ctx, stepCtx }); + } catch (err: unknown) { + stepCtx.logger.error(`Failed to upload build artifacts.`, err); + firstError ||= err; + } + + if (ctx.job.platform === Platform.IOS) { + try { + await uploadXcodeBuildLogs({ ctx, stepCtx }); + } catch (err: unknown) { + stepCtx.logger.error(`Failed to upload Xcode build logs.`, err); + } + } + + if (firstError) { + throw firstError; + } + }, + }); +} + +function resolveIosArtifactPath(job: Ios.Job): string { + if (job.applicationArchivePath) { + return job.applicationArchivePath; + } else if (job.simulator) { + return 'ios/build/Build/Products/*simulator/*.app'; + } else { + return 'ios/build/*.ipa'; + } +} + +async function uploadApplicationArchivesAsync({ + ctx, + stepCtx: { workingDirectory, logger }, +}: { + ctx: CustomBuildContext; + stepCtx: BuildStepContext; +}): Promise { + const applicationArchivePatternOrPath = + ctx.job.platform === Platform.ANDROID + ? ctx.job.applicationArchivePath ?? 'android/app/build/outputs/**/*.{apk,aab}' + : resolveIosArtifactPath(ctx.job); + const applicationArchives = await findArtifacts({ + rootDir: workingDirectory, + patternOrPath: applicationArchivePatternOrPath, + logger, + }); + + if (applicationArchives.length === 0) { + throw new Error(`Found no application archives for "${applicationArchivePatternOrPath}".`); + } + + const count = applicationArchives.length; + logger.info( + `Found ${count} application archive${count > 1 ? 's' : ''}:\n- ${applicationArchives.join( + '\n- ' + )}` + ); + + logger.info('Uploading...'); + await ctx.runtimeApi.uploadArtifact({ + artifact: { + type: ManagedArtifactType.APPLICATION_ARCHIVE, + paths: applicationArchives, + }, + logger, + }); + logger.info('Done.'); +} + +async function uploadBuildArtifacts({ + ctx, + stepCtx: { workingDirectory, logger }, +}: { + ctx: CustomBuildContext; + stepCtx: BuildStepContext; +}): Promise { + const buildArtifacts = ( + await Promise.all( + (ctx.job.buildArtifactPaths ?? []).map((path) => + findArtifacts({ rootDir: workingDirectory, patternOrPath: path, logger }) + ) + ) + ).flat(); + if (buildArtifacts.length === 0) { + return; + } + + logger.info(`Found additional build artifacts:\n- ${buildArtifacts.join('\n- ')}`); + logger.info('Uploading...'); + await ctx.runtimeApi.uploadArtifact({ + artifact: { + type: ManagedArtifactType.BUILD_ARTIFACTS, + paths: buildArtifacts, + }, + logger, + }); + logger.info('Done.'); +} + +async function uploadXcodeBuildLogs({ + ctx, + stepCtx: { logger, global }, +}: { + ctx: CustomBuildContext; + stepCtx: BuildStepContext; +}): Promise { + const xcodeBuildLogsPath = await findXcodeBuildLogsPathAsync(global.buildLogsDirectory); + if (!xcodeBuildLogsPath) { + return; + } + + logger.info(`Found Xcode build logs.`); + logger.info('Uploading...'); + await ctx.runtimeApi.uploadArtifact({ + artifact: { + type: ManagedArtifactType.XCODE_BUILD_LOGS, + paths: [xcodeBuildLogsPath], + }, + logger, + }); + logger.info('Done.'); +} diff --git a/packages/build-tools/src/steps/functions/generateGymfileFromTemplate.ts b/packages/build-tools/src/steps/functions/generateGymfileFromTemplate.ts new file mode 100644 index 0000000000..99430b6836 --- /dev/null +++ b/packages/build-tools/src/steps/functions/generateGymfileFromTemplate.ts @@ -0,0 +1,225 @@ +import assert from 'assert'; +import path from 'path'; + +import fs from 'fs-extra'; +import { BuildFunction, BuildStepInput, BuildStepInputValueTypeName } from '@expo/steps'; +import { Ios } from '@expo/eas-build-job'; +import { IOSConfig } from '@expo/config-plugins'; +import plist from '@expo/plist'; +import { bunyan } from '@expo/logger'; +import { templateString } from '@expo/template-file'; + +import { IosBuildCredentialsSchema } from '../utils/ios/credentials/credentials'; +import IosCredentialsManager, { Credentials } from '../utils/ios/credentials/manager'; +import { resolveBuildConfiguration, resolveScheme } from '../utils/ios/resolve'; +import { isTVOS } from '../utils/ios/tvos'; + +const DEFAULT_CREDENTIALS_TEMPLATE = ` + suppress_xcode_output(true) + clean(<%- CLEAN %>) + + scheme("<%- SCHEME %>") + <% if (BUILD_CONFIGURATION) { %> + configuration("<%- BUILD_CONFIGURATION %>") + <% } %> + + export_options({ + method: "<%- EXPORT_METHOD %>", + provisioningProfiles: {<% _.forEach(PROFILES, function(profile) { %> + "<%- profile.BUNDLE_ID %>" => "<%- profile.UUID %>",<% }); %> + }<% if (ICLOUD_CONTAINER_ENVIRONMENT) { %>, + iCloudContainerEnvironment: "<%- ICLOUD_CONTAINER_ENVIRONMENT %>" + <% } %> + }) + + export_xcargs "OTHER_CODE_SIGN_FLAGS=\\"--keychain <%- KEYCHAIN_PATH %>\\"" + + disable_xcpretty(true) + buildlog_path("<%- LOGS_DIRECTORY %>") + + output_directory("<%- OUTPUT_DIRECTORY %>") +`; + +const DEFAULT_SIMULATOR_TEMPLATE = ` + suppress_xcode_output(true) + clean(<%- CLEAN %>) + + scheme("<%- SCHEME %>") + <% if (BUILD_CONFIGURATION) { %> + configuration("<%- BUILD_CONFIGURATION %>") + <% } %> + + derived_data_path("<%- DERIVED_DATA_PATH %>") + skip_package_ipa(true) + skip_archive(true) + destination("<%- SCHEME_SIMULATOR_DESTINATION %>") + + disable_xcpretty(true) + buildlog_path("<%- LOGS_DIRECTORY %>") +`; + +export function generateGymfileFromTemplateFunction(): BuildFunction { + return new BuildFunction({ + namespace: 'eas', + id: 'generate_gymfile_from_template', + name: 'Generate Gymfile from template', + inputProviders: [ + BuildStepInput.createProvider({ + id: 'template', + required: false, + allowedValueTypeName: BuildStepInputValueTypeName.STRING, + }), + BuildStepInput.createProvider({ + id: 'credentials', + required: false, + allowedValueTypeName: BuildStepInputValueTypeName.JSON, + }), + BuildStepInput.createProvider({ + id: 'build_configuration', + required: false, + allowedValueTypeName: BuildStepInputValueTypeName.STRING, + }), + BuildStepInput.createProvider({ + id: 'scheme', + required: false, + allowedValueTypeName: BuildStepInputValueTypeName.STRING, + }), + BuildStepInput.createProvider({ + id: 'clean', + required: true, + allowedValueTypeName: BuildStepInputValueTypeName.BOOLEAN, + defaultValue: false, + }), + BuildStepInput.createProvider({ + id: 'extra', + required: false, + allowedValueTypeName: BuildStepInputValueTypeName.JSON, + }), + ], + fn: async (stepCtx, { inputs }) => { + let credentials: Credentials | undefined = undefined; + const rawCredentialsInput = inputs.credentials.value as Record | undefined; + if (rawCredentialsInput) { + const { value, error } = IosBuildCredentialsSchema.validate(rawCredentialsInput, { + stripUnknown: true, + convert: true, + abortEarly: false, + }); + if (error) { + throw error; + } + + const credentialsManager = new IosCredentialsManager(value); + credentials = await credentialsManager.prepare(stepCtx.logger); + } + + const extra: Record = + (inputs.extra.value as Record | undefined) ?? {}; + + const templateInput = inputs.template.value as string | undefined; + + let template: string; + if (templateInput) { + template = templateInput; + } else if (credentials) { + template = DEFAULT_CREDENTIALS_TEMPLATE; + } else { + template = DEFAULT_SIMULATOR_TEMPLATE; + } + + assert(stepCtx.global.staticContext.job, 'Job is not defined'); + const job = stepCtx.global.staticContext.job as Ios.Job; + const buildConfiguration = resolveBuildConfiguration( + job, + inputs.build_configuration.value as string | undefined + ); + const scheme = resolveScheme( + stepCtx.workingDirectory, + job, + inputs.scheme.value as string | undefined + ); + const entitlements = await maybeReadEntitlementsAsync( + stepCtx.logger, + stepCtx.workingDirectory, + scheme, + buildConfiguration + ); + + const gymfilePath = path.join(stepCtx.workingDirectory, 'ios/Gymfile'); + + const PROFILES: { BUNDLE_ID: string; UUID: string }[] = []; + if (credentials) { + const targets = Object.keys(credentials.targetProvisioningProfiles); + for (const target of targets) { + const profile = credentials.targetProvisioningProfiles[target]; + PROFILES.push({ + BUNDLE_ID: profile.bundleIdentifier, + UUID: profile.uuid, + }); + } + } + + const ICLOUD_CONTAINER_ENVIRONMENT = ( + entitlements as Record> + )?.['com.apple.developer.icloud-container-environment'] as string | undefined; + + const isTV = await isTVOS({ + scheme, + buildConfiguration, + workingDir: stepCtx.workingDirectory, + }); + const simulatorDestination = `generic/platform=${isTV ? 'tvOS' : 'iOS'} Simulator`; + + const output = templateString({ + input: template, + vars: { + SCHEME: scheme, + BUILD_CONFIGURATION: buildConfiguration, + OUTPUT_DIRECTORY: './build', + CLEAN: String(inputs.clean.value), + LOGS_DIRECTORY: stepCtx.global.buildLogsDirectory, + ICLOUD_CONTAINER_ENVIRONMENT, + SCHEME_SIMULATOR_DESTINATION: simulatorDestination, + DERIVED_DATA_PATH: './build', + ...(PROFILES ? { PROFILES } : {}), + ...(credentials + ? { + KEYCHAIN_PATH: credentials.keychainPath, + EXPORT_METHOD: credentials.distributionType, + } + : {}), + ...extra, + }, + mustache: false, + }); + await fs.writeFile(gymfilePath, output); + + const gymfileContents = await fs.readFile(gymfilePath, 'utf8'); + stepCtx.logger.info(`Successfully generated Gymfile: ${gymfileContents}`); + }, + }); +} + +async function maybeReadEntitlementsAsync( + logger: bunyan, + workingDir: string, + scheme: string, + buildConfiguration: string +): Promise { + try { + const applicationTargetName = + await IOSConfig.BuildScheme.getApplicationTargetNameForSchemeAsync(workingDir, scheme); + const entitlementsPath = IOSConfig.Entitlements.getEntitlementsPath(workingDir, { + buildConfiguration, + targetName: applicationTargetName, + }); + if (!entitlementsPath) { + return null; + } + const entitlementsRaw = await fs.readFile(entitlementsPath, 'utf8'); + return plist.parse(entitlementsRaw); + } catch (err) { + logger.warn({ err }, 'Failed to read entitlements'); + return null; + } +} diff --git a/packages/build-tools/src/steps/functions/getCredentialsForBuildTriggeredByGitHubIntegration.ts b/packages/build-tools/src/steps/functions/getCredentialsForBuildTriggeredByGitHubIntegration.ts new file mode 100644 index 0000000000..898d4a78b9 --- /dev/null +++ b/packages/build-tools/src/steps/functions/getCredentialsForBuildTriggeredByGitHubIntegration.ts @@ -0,0 +1,24 @@ +import { BuildJob } from '@expo/eas-build-job'; +import { BuildFunction } from '@expo/steps'; + +import { CustomBuildContext } from '../../customBuildContext'; + +import { resolveBuildConfigAsync } from './resolveBuildConfig'; + +export function createGetCredentialsForBuildTriggeredByGithubIntegration( + ctx: CustomBuildContext +): BuildFunction { + return new BuildFunction({ + namespace: 'eas', + id: 'get_credentials_for_build_triggered_by_github_integration', + name: 'Get credentials for build triggered by GitHub integration', + fn: async (stepCtx, { env }) => { + await resolveBuildConfigAsync({ + logger: stepCtx.logger, + env, + workingDirectory: stepCtx.workingDirectory, + ctx, + }); + }, + }); +} diff --git a/packages/build-tools/src/steps/functions/injectAndroidCredentials.ts b/packages/build-tools/src/steps/functions/injectAndroidCredentials.ts new file mode 100644 index 0000000000..e3ef18e1d4 --- /dev/null +++ b/packages/build-tools/src/steps/functions/injectAndroidCredentials.ts @@ -0,0 +1,81 @@ +import path from 'path'; + +import { v4 as uuidv4 } from 'uuid'; +import { + BuildFunction, + BuildStepContext, + BuildStepInput, + BuildStepInputValueTypeName, +} from '@expo/steps'; +import fs from 'fs-extra'; +import Joi from 'joi'; +import { Android } from '@expo/eas-build-job'; + +import { injectCredentialsGradleConfig } from '../utils/android/gradleConfig'; + +const KeystoreSchema = Joi.object({ + dataBase64: Joi.string().required(), + keystorePassword: Joi.string().allow('').required(), + keyAlias: Joi.string().required(), + keyPassword: Joi.string().allow(''), +}); + +const AndroidBuildCredentialsSchema = Joi.object<{ keystore: Android.Keystore }>({ + keystore: KeystoreSchema.required(), +}).required(); + +export function injectAndroidCredentialsFunction(): BuildFunction { + return new BuildFunction({ + namespace: 'eas', + id: 'inject_android_credentials', + name: 'Inject Android credentials', + inputProviders: [ + BuildStepInput.createProvider({ + id: 'credentials', + required: true, + allowedValueTypeName: BuildStepInputValueTypeName.JSON, + defaultValue: '${ eas.job.secrets.buildCredentials }', + }), + ], + fn: async (stepCtx, { inputs }) => { + const rawCredentialsInput = inputs.credentials.value as Record; + const { value, error } = AndroidBuildCredentialsSchema.validate(rawCredentialsInput, { + stripUnknown: true, + convert: true, + abortEarly: false, + }); + if (error) { + throw error; + } + const credentials = value; + + await restoreCredentials(stepCtx, credentials); + await injectCredentialsGradleConfig(stepCtx.logger, stepCtx.workingDirectory); + }, + }); +} + +async function restoreCredentials( + stepsCtx: BuildStepContext, + buildCredentials: { + keystore: Android.Keystore; + } +): Promise { + stepsCtx.logger.info("Writing secrets to the project's directory"); + const keystorePath = path.join(stepsCtx.global.projectTargetDirectory, `keystore-${uuidv4()}`); + await fs.writeFile(keystorePath, new Uint8Array(Buffer.from(buildCredentials.keystore.dataBase64, 'base64'))); + const credentialsJson = { + android: { + keystore: { + keystorePath, + keystorePassword: buildCredentials.keystore.keystorePassword, + keyAlias: buildCredentials.keystore.keyAlias, + keyPassword: buildCredentials.keystore.keyPassword, + }, + }, + }; + await fs.writeFile( + path.join(stepsCtx.global.projectTargetDirectory, 'credentials.json'), + JSON.stringify(credentialsJson) + ); +} diff --git a/packages/build-tools/src/steps/functions/installMaestro.ts b/packages/build-tools/src/steps/functions/installMaestro.ts new file mode 100644 index 0000000000..b488cc517d --- /dev/null +++ b/packages/build-tools/src/steps/functions/installMaestro.ts @@ -0,0 +1,279 @@ +import assert from 'assert'; +import fs from 'fs'; +import os from 'os'; +import path from 'path'; + +import { + BuildFunction, + BuildRuntimePlatform, + BuildStepEnv, + BuildStepGlobalContext, + BuildStepInput, + BuildStepInputValueTypeName, + BuildStepOutput, +} from '@expo/steps'; +import spawn from '@expo/turtle-spawn'; +import { bunyan } from '@expo/logger'; +import { asyncResult } from '@expo/results'; + +export function createInstallMaestroBuildFunction(): BuildFunction { + return new BuildFunction({ + namespace: 'eas', + id: 'install_maestro', + name: 'Install Maestro', + inputProviders: [ + BuildStepInput.createProvider({ + id: 'maestro_version', + required: false, + allowedValueTypeName: BuildStepInputValueTypeName.STRING, + }), + ], + outputProviders: [ + BuildStepOutput.createProvider({ + id: 'maestro_version', + required: false, + }), + ], + fn: async ({ logger, global }, { inputs, env, outputs }) => { + const requestedMaestroVersion = inputs.maestro_version.value as string | undefined; + const { value: currentMaestroVersion } = await asyncResult(getMaestroVersion({ env })); + + // When not running in EAS Build VM, do not modify local environment. + if (env.EAS_BUILD_RUNNER !== 'eas-build') { + const currentIsJavaInstalled = await isJavaInstalled({ env }); + const currentIsIdbInstalled = await isIdbInstalled({ env }); + + if (!currentIsJavaInstalled) { + logger.warn( + 'It seems Java is not installed. It is required to run Maestro. If the job fails, this may be the reason.' + ); + logger.info(''); + } + + if (!currentIsIdbInstalled) { + logger.warn( + 'It seems IDB is not installed. Maestro requires it to run flows on iOS Simulator. If the job fails, this may be the reason.' + ); + logger.info(''); + } + + if (!currentMaestroVersion) { + logger.warn( + 'It seems Maestro is not installed. Please install Maestro manually and rerun the job.' + ); + logger.info(''); + } + + // Guide is helpful in these two cases, it doesn't mention Java. + if (!currentIsIdbInstalled || !currentMaestroVersion) { + logger.warn( + 'For more info, check out Maestro installation guide: https://maestro.mobile.dev/getting-started/installing-maestro' + ); + } + + if (currentMaestroVersion) { + outputs.maestro_version.set(currentMaestroVersion); + logger.info(`Maestro ${currentMaestroVersion} is ready.`); + } + + return; + } + + if (!(await isJavaInstalled({ env }))) { + if (global.runtimePlatform === BuildRuntimePlatform.DARWIN) { + logger.info('Installing Java'); + await installJavaFromGcs({ logger, env }); + } else { + // We expect Java to be pre-installed on Linux images, + // so this should only happen when running this step locally. + // We don't need to support installing Java on local computers. + throw new Error('Please install Java manually and rerun the job.'); + } + } + + // IDB is only a requirement on macOS. + if ( + global.runtimePlatform === BuildRuntimePlatform.DARWIN && + !(await isIdbInstalled({ env })) + ) { + logger.info('Installing IDB'); + await installIdbFromBrew({ logger, env }); + } + + // Skip installing if the input sets a specific Maestro version to install + // and it is already installed which happens when developing on a local computer. + if ( + !currentMaestroVersion || + (requestedMaestroVersion && requestedMaestroVersion !== currentMaestroVersion) + ) { + await installMaestro({ + version: requestedMaestroVersion, + global, + logger, + env, + }); + } + + const maestroVersionResult = await asyncResult(getMaestroVersion({ env })); + if (!maestroVersionResult.ok) { + logger.error(maestroVersionResult.reason, 'Failed to get Maestro version.'); + + throw new Error('Failed to ensure Maestro is installed.'); + } + + logger.info(`Maestro ${maestroVersionResult.value} is ready.`); + outputs.maestro_version.set(maestroVersionResult.value); + }, + }); +} + +async function getMaestroVersion({ env }: { env: BuildStepEnv }): Promise { + const maestroVersion = await spawn('maestro', ['--version'], { stdio: 'pipe', env }); + return maestroVersion.stdout.trim(); +} + +async function installMaestro({ + global, + version, + logger, + env, +}: { + version?: string; + logger: bunyan; + global: BuildStepGlobalContext; + env: BuildStepEnv; +}): Promise { + logger.info('Fetching install script'); + const tempDirectory = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'install_maestro')); + try { + const installMaestroScriptResponse = await fetch('https://get.maestro.mobile.dev'); + const installMaestroScript = await installMaestroScriptResponse.text(); + const installMaestroScriptFilePath = path.join(tempDirectory, 'install_maestro.sh'); + await fs.promises.writeFile(installMaestroScriptFilePath, installMaestroScript, { + mode: 0o777, + }); + logger.info('Installing Maestro'); + assert( + env.HOME, + 'Failed to infer directory to install Maestro in: $HOME environment variable is empty.' + ); + const maestroDir = path.join(env.HOME, '.maestro'); + await spawn(installMaestroScriptFilePath, [], { + logger, + env: { + ...env, + MAESTRO_DIR: maestroDir, + // _Not_ providing MAESTRO_VERSION installs latest. + // MAESTRO_VERSION is used to interpolate the download URL like github.com/releases/cli-$MAESTRO_VERSION... + MAESTRO_VERSION: version === 'latest' ? undefined : version, + }, + }); + // That's where Maestro installs binary as of February 2024 + // I suspect/hope they don't change the location. + const maestroBinDir = path.join(maestroDir, 'bin'); + global.updateEnv({ + ...global.env, + PATH: `${global.env.PATH}:${maestroBinDir}`, + }); + env.PATH = `${env.PATH}:${maestroBinDir}`; + process.env.PATH = `${process.env.PATH}:${maestroBinDir}`; + } finally { + await fs.promises.rm(tempDirectory, { force: true, recursive: true }); + } +} + +async function isIdbInstalled({ env }: { env: BuildStepEnv }): Promise { + try { + await spawn('idb', ['-h'], { ignoreStdio: true, env }); + return true; + } catch { + return false; + } +} + +async function installIdbFromBrew({ + logger, + env, +}: { + logger: bunyan; + env: BuildStepEnv; +}): Promise { + // Unfortunately our Mac images sometimes have two Homebrew + // installations. We should use the ARM64 one, located in /opt/homebrew. + const brewPath = '/opt/homebrew/bin/brew'; + const localEnv = { + ...env, + HOMEBREW_NO_AUTO_UPDATE: '1', + HOMEBREW_NO_INSTALL_CLEANUP: '1', + }; + + await spawn(brewPath, ['tap', 'facebook/fb'], { + env: localEnv, + logger, + }); + await spawn(brewPath, ['install', 'idb-companion'], { + env: localEnv, + logger, + }); +} + +async function isJavaInstalled({ env }: { env: BuildStepEnv }): Promise { + try { + await spawn('java', ['-version'], { ignoreStdio: true, env }); + return true; + } catch { + return false; + } +} + +/** + * Installs Java 17 from a file uploaded manually to GCS as cache. + * Should not be run outside of EAS Build VMs not to break users' environments. + */ +async function installJavaFromGcs({ + logger, + env, +}: { + logger: bunyan; + env: BuildStepEnv; +}): Promise { + const downloadUrl = + 'https://storage.googleapis.com/turtle-v2/zulu17.60.17-ca-jdk17.0.16-macosx_aarch64.dmg'; + const filename = path.basename(downloadUrl); + const tempDirectory = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'install_java')); + const installerPath = path.join(tempDirectory, filename); + const installerMountDirectory = path.join(tempDirectory, 'mountpoint'); + try { + logger.info('Downloading Java installer'); + // This is simpler than piping body into a write stream with node-fetch. + await spawn('curl', ['--output', installerPath, downloadUrl], { env }); + + await fs.promises.mkdir(installerMountDirectory); + logger.info('Mounting Java installer'); + await spawn( + 'hdiutil', + ['attach', installerPath, '-noverify', '-mountpoint', installerMountDirectory], + { env } + ); + + logger.info('Installing Java'); + await spawn( + 'sudo', + [ + 'installer', + '-pkg', + path.join(installerMountDirectory, 'Double-Click to Install Azul Zulu JDK 17.pkg'), + '-target', + '/', + ], + { env } + ); + } finally { + try { + // We need to unmount to remove, otherwise we get "resource busy" + await spawn('hdiutil', ['detach', installerMountDirectory], { env }); + } catch {} + + await fs.promises.rm(tempDirectory, { force: true, recursive: true }); + } +} diff --git a/packages/build-tools/src/steps/functions/installNodeModules.ts b/packages/build-tools/src/steps/functions/installNodeModules.ts new file mode 100644 index 0000000000..539ecf735d --- /dev/null +++ b/packages/build-tools/src/steps/functions/installNodeModules.ts @@ -0,0 +1,79 @@ +import path from 'path'; + +import { BuildFunction, BuildStepEnv } from '@expo/steps'; +import { BuildStepContext } from '@expo/steps/dist_esm/BuildStepContext'; + +import { + findPackagerRootDir, + getPackageVersionFromPackageJson, + resolvePackageManager, + shouldUseFrozenLockfile, +} from '../../utils/packageManager'; +import { installDependenciesAsync } from '../../common/installDependencies'; +import { readPackageJson } from '../../utils/project'; + +export function createInstallNodeModulesBuildFunction(): BuildFunction { + return new BuildFunction({ + namespace: 'eas', + id: 'install_node_modules', + name: 'Install node modules', + fn: async (stepCtx, { env }) => { + await installNodeModules(stepCtx, env); + }, + }); +} + +export async function installNodeModules( + stepCtx: BuildStepContext, + env: BuildStepEnv +): Promise { + const { logger } = stepCtx; + const packageManager = resolvePackageManager(stepCtx.workingDirectory); + const packagerRunDir = findPackagerRootDir(stepCtx.workingDirectory); + + if (packagerRunDir !== stepCtx.workingDirectory) { + const relativeReactNativeProjectDirectory = path.relative( + stepCtx.global.projectTargetDirectory, + stepCtx.workingDirectory + ); + logger.info( + `We detected that '${relativeReactNativeProjectDirectory}' is a ${packageManager} workspace` + ); + } + + let packageJson = {}; + try { + packageJson = readPackageJson(stepCtx.workingDirectory); + } catch { + logger.info( + `Failed to read package.json, defaulting to installing dependencies with a frozen lockfile. You can use EAS_NO_FROZEN_LOCKFILE=1 to disable it.` + ); + } + + const expoVersion = + stepCtx.global.staticContext.metadata?.sdkVersion ?? + getPackageVersionFromPackageJson({ + packageJson, + packageName: 'expo', + }); + + const reactNativeVersion = + stepCtx.global.staticContext.metadata?.reactNativeVersion ?? + getPackageVersionFromPackageJson({ + packageJson, + packageName: 'react-native', + }); + + const { spawnPromise } = await installDependenciesAsync({ + packageManager, + env, + logger: stepCtx.logger, + cwd: packagerRunDir, + useFrozenLockfile: shouldUseFrozenLockfile({ + env, + sdkVersion: expoVersion, + reactNativeVersion, + }), + }); + await spawnPromise; +} diff --git a/packages/build-tools/src/steps/functions/installPods.ts b/packages/build-tools/src/steps/functions/installPods.ts new file mode 100644 index 0000000000..a904d003b6 --- /dev/null +++ b/packages/build-tools/src/steps/functions/installPods.ts @@ -0,0 +1,35 @@ +import { BuildFunction } from '@expo/steps'; +import spawn from '@expo/turtle-spawn'; + +export function createInstallPodsBuildFunction(): BuildFunction { + return new BuildFunction({ + namespace: 'eas', + id: 'install_pods', + name: 'Install Pods', + fn: async (stepsCtx, { env }) => { + stepsCtx.logger.info('Installing pods'); + const verboseFlag = stepsCtx.global.env['EAS_VERBOSE'] === '1' ? ['--verbose'] : []; + const cocoapodsDeploymentFlag = + stepsCtx.global.env['POD_INSTALL_DEPLOYMENT'] === '1' ? ['--deployment'] : []; + + await spawn('pod', ['install', ...verboseFlag, ...cocoapodsDeploymentFlag], { + logger: stepsCtx.logger, + env: { + ...env, + LANG: 'en_US.UTF-8', + }, + cwd: stepsCtx.workingDirectory, + lineTransformer: (line?: string) => { + if ( + !line || + /\[!\] '[\w-]+' uses the unencrypted 'http' protocol to transfer the Pod\./.exec(line) + ) { + return null; + } else { + return line; + } + }, + }); + }, + }); +} diff --git a/packages/build-tools/src/steps/functions/internalMaestroTest.ts b/packages/build-tools/src/steps/functions/internalMaestroTest.ts new file mode 100644 index 0000000000..712d20457c --- /dev/null +++ b/packages/build-tools/src/steps/functions/internalMaestroTest.ts @@ -0,0 +1,616 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import os from 'node:os'; +import { setTimeout } from 'node:timers/promises'; + +import { + BuildFunction, + BuildStepEnv, + BuildStepInput, + BuildStepInputValueTypeName, + BuildStepOutput, + spawnAsync, +} from '@expo/steps'; +import { z } from 'zod'; +import spawn, { SpawnPromise, SpawnResult } from '@expo/turtle-spawn'; +import { PipeMode, bunyan } from '@expo/logger'; +import { Result, asyncResult, result } from '@expo/results'; +import { GenericArtifactType } from '@expo/eas-build-job'; + +import { CustomBuildContext } from '../../customBuildContext'; +import { IosSimulatorName, IosSimulatorUtils } from '../../utils/IosSimulatorUtils'; +import { + AndroidDeviceSerialId, + AndroidEmulatorUtils, + AndroidVirtualDeviceName, +} from '../../utils/AndroidEmulatorUtils'; +import { PlatformToProperNounMap } from '../../utils/strings'; +import { findMaestroPathsFlowsToExecuteAsync } from '../../utils/findMaestroPathsFlowsToExecuteAsync'; + +export function createInternalEasMaestroTestFunction(ctx: CustomBuildContext): BuildFunction { + return new BuildFunction({ + namespace: 'eas', + id: '__maestro_test', + inputProviders: [ + BuildStepInput.createProvider({ + allowedValueTypeName: BuildStepInputValueTypeName.STRING, + id: 'platform', + required: true, + }), + BuildStepInput.createProvider({ + allowedValueTypeName: BuildStepInputValueTypeName.JSON, + id: 'flow_paths', + required: true, + }), + BuildStepInput.createProvider({ + allowedValueTypeName: BuildStepInputValueTypeName.NUMBER, + id: 'retries', + defaultValue: 1, + required: false, + }), + BuildStepInput.createProvider({ + allowedValueTypeName: BuildStepInputValueTypeName.STRING, + id: 'include_tags', + required: false, + }), + BuildStepInput.createProvider({ + allowedValueTypeName: BuildStepInputValueTypeName.STRING, + id: 'exclude_tags', + required: false, + }), + BuildStepInput.createProvider({ + allowedValueTypeName: BuildStepInputValueTypeName.NUMBER, + id: 'shards', + defaultValue: 1, + required: false, + }), + BuildStepInput.createProvider({ + allowedValueTypeName: BuildStepInputValueTypeName.STRING, + id: 'output_format', + required: false, + }), + BuildStepInput.createProvider({ + allowedValueTypeName: BuildStepInputValueTypeName.BOOLEAN, + id: 'record_screen', + defaultValue: false, + required: false, + }), + ], + outputProviders: [ + BuildStepOutput.createProvider({ + id: 'test_reports_artifact_id', + required: false, + }), + ], + fn: async (stepCtx, { inputs: _inputs, env, outputs }) => { + // inputs come in form of { value: unknown }. Here we parse them into a typed and validated object. + const { + platform, + flow_paths, + retries, + include_tags, + exclude_tags, + shards, + output_format, + record_screen, + } = z + .object({ + platform: z.enum(['ios', 'android']), + flow_paths: z.array(z.string()), + retries: z.number().default(1), + include_tags: z.string().optional(), + exclude_tags: z.string().optional(), + shards: z.number().default(1), + output_format: z.string().optional(), + record_screen: z.boolean().default(false), + }) + .parse( + Object.fromEntries(Object.entries(_inputs).map(([key, value]) => [key, value.value])) + ); + + const flowPathsToExecute: string[] = []; + for (const flowPath of flow_paths) { + const flowPaths = await findMaestroPathsFlowsToExecuteAsync({ + workingDirectory: stepCtx.workingDirectory, + flowPath, + logger: stepCtx.logger, + includeTags: include_tags ? include_tags.split(',') : undefined, + excludeTags: exclude_tags ? exclude_tags.split(',') : undefined, + }); + if (flowPaths.length === 0) { + stepCtx.logger.warn(`No flows to execute found in "${flowPath}".`); + continue; + } + stepCtx.logger.info( + `Marking for execution:\n- ${flowPaths.map((flowPath) => path.relative(stepCtx.workingDirectory, flowPath)).join('\n- ')}` + ); + stepCtx.logger.info(''); + flowPathsToExecute.push(...flowPaths); + } + + // TODO: Add support for shards. (Shouldn't be too difficult.) + if (shards > 1) { + stepCtx.logger.warn( + 'Sharding support has been temporarily disabled. Running tests on a single shard.' + ); + } + + // eas/__maestro_test does not start devices, it expects a single device to be already running + // and configured with the app. Here we find the booted device and stop it. + + let sourceDeviceIdentifier: IosSimulatorName | AndroidVirtualDeviceName; + + switch (platform) { + case 'ios': { + const bootedDevices = await IosSimulatorUtils.getAvailableDevicesAsync({ + env, + filter: 'booted', + }); + if (bootedDevices.length === 0) { + throw new Error('No booted iOS Simulator found.'); + } else if (bootedDevices.length > 1) { + throw new Error('Multiple booted iOS Simulators found.'); + } + + const device = bootedDevices[0]; + stepCtx.logger.info(`Running tests on iOS Simulator: ${device.name}.`); + + stepCtx.logger.info(`Preparing Simulator for tests...`); + await spawnAsync('xcrun', ['simctl', 'shutdown', device.udid], { + logger: stepCtx.logger, + stdio: 'pipe', + }); + + sourceDeviceIdentifier = device.name; + break; + } + case 'android': { + const connectedDevices = await AndroidEmulatorUtils.getAttachedDevicesAsync({ env }); + if (connectedDevices.length === 0) { + throw new Error('No booted Android Emulator found.'); + } else if (connectedDevices.length > 1) { + throw new Error('Multiple booted Android Emulators found.'); + } + + const { serialId } = connectedDevices[0]; + const adbEmuAvdNameResult = await spawn('adb', ['-s', serialId, 'emu', 'avd', 'name'], { + mode: PipeMode.COMBINED, + env, + }); + const avdName = adbEmuAvdNameResult.stdout + .replace(/\r\n/g, '\n') + .split('\n')[0] as AndroidVirtualDeviceName; + stepCtx.logger.info(`Running tests on Android Emulator: ${avdName}.`); + + stepCtx.logger.info(`Preparing Emulator for tests...`); + await spawnAsync('adb', ['-s', serialId, 'emu', 'kill'], { + stdio: 'pipe', + }); + // Waiting for emulator to get killed, see ANDROID_EMULATOR_WAIT_TIME_BEFORE_KILL. + await setTimeout(1000); + + sourceDeviceIdentifier = avdName; + break; + } + } + + // During tests we generate reports and device logs. We store them in temporary directories + // and upload them once all tests are done. When a test is retried, new reports overwrite + // the old ones. The files are named "flow-${index}" for easier identification. + const maestroReportsDir = await fs.promises.mkdtemp( + path.join(os.tmpdir(), 'maestro-reports-') + ); + const deviceLogsDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'device-logs-')); + + const failedFlows: string[] = []; + + for (const [flowIndex, flowPath] of flowPathsToExecute.entries()) { + stepCtx.logger.info(''); + + // If output_format is empty or noop, we won't use this. + const outputPath = path.join( + maestroReportsDir, + [ + `${output_format ? output_format + '-' : ''}report-flow-${flowIndex + 1}`, + MaestroOutputFormatToExtensionMap[output_format ?? 'noop'], + ] + .filter(Boolean) + .join('.') + ); + + for (let attemptCount = 0; attemptCount < retries; attemptCount++) { + const localDeviceName = `eas-simulator-${flowIndex}-${attemptCount}` as + | IosSimulatorName + | AndroidVirtualDeviceName; + + // If the test passes, but the recording fails, we don't want to make the test fail, + // so we return two separate results. + const { + fnResult: { fnResult, recordingResult }, + logsResult, + } = await withCleanDeviceAsync({ + platform, + sourceDeviceIdentifier, + localDeviceName, + env, + logger: stepCtx.logger, + fn: async ({ deviceIdentifier }) => { + return await maybeWithScreenRecordingAsync({ + shouldRecord: record_screen, + platform, + deviceIdentifier, + env, + logger: stepCtx.logger, + fn: async () => { + stepCtx.logger.info(''); + + const [command, ...args] = getMaestroTestCommand({ + flow_path: flowPath, + output_format, + output_path: outputPath, + }); + + try { + await spawnAsync(command, args, { + logger: stepCtx.logger, + cwd: stepCtx.workingDirectory, + env, + stdio: 'pipe', + }); + } finally { + stepCtx.logger.info(''); + } + }, + }); + }, + }); + + // Move device logs to the device logs directory. + if (logsResult?.ok) { + try { + const extension = path.extname(logsResult.value.outputPath); + const destinationPath = path.join(deviceLogsDir, `flow-${flowIndex}${extension}`); + + await fs.promises.rm(destinationPath, { + force: true, + recursive: true, + }); + await fs.promises.rename(logsResult.value.outputPath, destinationPath); + } catch (err) { + stepCtx.logger.warn({ err }, 'Failed to prepare device logs for upload.'); + } + } else if (logsResult?.reason) { + stepCtx.logger.error({ err: logsResult.reason }, 'Failed to collect device logs.'); + } + + const isLastAttempt = fnResult.ok || attemptCount === retries - 1; + if (isLastAttempt && recordingResult.value) { + try { + await ctx.runtimeApi.uploadArtifact({ + logger: stepCtx.logger, + artifact: { + // TODO(sjchmiela): Add metadata to artifacts so we don't need to encode flow path and attempt in the name. + name: `Screen Recording (${flowIndex}-${path.basename(flowPath, path.extname(flowPath))})`, + paths: [recordingResult.value], + type: GenericArtifactType.OTHER, + }, + }); + } catch (err) { + stepCtx.logger.warn({ err }, 'Failed to upload screen recording.'); + } + } + + if (fnResult.ok) { + stepCtx.logger.info(`Test passed.`); + // Break out of the retry loop. + break; + } + + if (attemptCount < retries - 1) { + stepCtx.logger.info(`Retrying test...`); + stepCtx.logger.info(''); + continue; + } + + // fnResult.reason is not super interesting, but it does print out the full command so we can keep it for debugging purposes. + stepCtx.logger.error({ err: fnResult.reason }, 'Test errored.'); + failedFlows.push(flowPath); + } + } + + stepCtx.logger.info(''); + + // When all tests are done, we upload the reports and device logs. + const generatedMaestroReports = await fs.promises.readdir(maestroReportsDir); + if (generatedMaestroReports.length === 0) { + stepCtx.logger.warn('No reports were generated.'); + } else { + stepCtx.logger.info(`Uploading reports...`); + try { + const { artifactId } = await ctx.runtimeApi.uploadArtifact({ + logger: stepCtx.logger, + artifact: { + name: `${PlatformToProperNounMap[platform]} Maestro Test Reports (${output_format})`, + paths: [maestroReportsDir], + type: GenericArtifactType.OTHER, + }, + }); + if (artifactId) { + outputs.test_reports_artifact_id.set(artifactId); + } + } catch (err) { + stepCtx.logger.error({ err }, 'Failed to upload reports.'); + } + } + + const generatedDeviceLogs = await fs.promises.readdir(deviceLogsDir); + if (generatedDeviceLogs.length === 0) { + stepCtx.logger.warn('No device logs were successfully collected.'); + } else { + stepCtx.logger.info(`Uploading device logs...`); + try { + await ctx.runtimeApi.uploadArtifact({ + logger: stepCtx.logger, + artifact: { + name: `Maestro Test Device Logs`, + paths: [deviceLogsDir], + type: GenericArtifactType.OTHER, + }, + }); + } catch (err) { + stepCtx.logger.error({ err }, 'Failed to upload device logs.'); + } + } + + stepCtx.logger.info(''); + + // If any tests failed, we throw an error to mark the step as failed. + if (failedFlows.length > 0) { + throw new Error( + `Some Maestro tests failed:\n- ${failedFlows + .map((flowPath) => path.relative(stepCtx.workingDirectory, flowPath)) + .join('\n- ')}` + ); + } else { + stepCtx.logger.info('All Maestro tests passed.'); + } + }, + }); +} + +export function getMaestroTestCommand(params: { + flow_path: string; + output_format: string | undefined; + /** Unused if `output_format` is undefined */ + output_path: string; +}): [command: string, ...args: string[]] { + let outputFormatFlags: string[] = []; + if (params.output_format) { + outputFormatFlags = [`--format`, params.output_format, `--output`, params.output_path]; + } + + return ['maestro', 'test', ...outputFormatFlags, params.flow_path] as [ + command: string, + ...args: string[], + ]; +} + +const MaestroOutputFormatToExtensionMap: Record = { + junit: 'xml', + html: 'html', +}; + +async function withCleanDeviceAsync({ + platform, + sourceDeviceIdentifier, + localDeviceName, + env, + logger, + fn, +}: { + env: BuildStepEnv; + logger: bunyan; + platform: 'ios' | 'android'; + sourceDeviceIdentifier: IosSimulatorName | AndroidVirtualDeviceName; + localDeviceName: IosSimulatorName | AndroidVirtualDeviceName; + fn: ({ + deviceIdentifier, + }: { + deviceIdentifier: IosSimulatorName | AndroidDeviceSerialId; + }) => Promise; +}): Promise<{ fnResult: TResult; logsResult: Result<{ outputPath: string }> | null }> { + // Clone and start the device + + let localDeviceIdentifier: IosSimulatorName | AndroidDeviceSerialId; + + switch (platform) { + case 'ios': { + logger.info(`Cloning iOS Simulator ${sourceDeviceIdentifier} to ${localDeviceName}...`); + await IosSimulatorUtils.cloneAsync({ + sourceDeviceIdentifier: sourceDeviceIdentifier as IosSimulatorName, + destinationDeviceName: localDeviceName as IosSimulatorName, + env, + }); + logger.info(`Starting iOS Simulator ${localDeviceName}...`); + const { udid } = await IosSimulatorUtils.startAsync({ + deviceIdentifier: localDeviceName as IosSimulatorName, + env, + }); + logger.info(`Waiting for iOS Simulator ${localDeviceName} to be ready...`); + await IosSimulatorUtils.waitForReadyAsync({ + udid, + env, + }); + localDeviceIdentifier = localDeviceName as IosSimulatorName; + break; + } + case 'android': { + logger.info(`Cloning Android Emulator ${sourceDeviceIdentifier} to ${localDeviceName}...`); + await AndroidEmulatorUtils.cloneAsync({ + sourceDeviceName: sourceDeviceIdentifier as AndroidVirtualDeviceName, + destinationDeviceName: localDeviceName as AndroidVirtualDeviceName, + env, + logger, + }); + logger.info(`Starting Android Emulator ${localDeviceName}...`); + const { serialId } = await AndroidEmulatorUtils.startAsync({ + deviceName: localDeviceName as AndroidVirtualDeviceName, + env, + }); + logger.info(`Waiting for Android Emulator ${localDeviceName} to be ready...`); + await AndroidEmulatorUtils.waitForReadyAsync({ + serialId, + env, + }); + localDeviceIdentifier = serialId; + break; + } + } + + // Run the function + + const fnResult = await asyncResult(fn({ deviceIdentifier: localDeviceIdentifier })); + + // Stop the device + + let logsResult: Result<{ outputPath: string }> | null = null; + + try { + switch (platform) { + case 'ios': { + logger.info(`Collecting logs from ${localDeviceName}...`); + logsResult = await asyncResult( + IosSimulatorUtils.collectLogsAsync({ + deviceIdentifier: localDeviceIdentifier as IosSimulatorName, + env, + }) + ); + + logger.info(`Cleaning up ${localDeviceName}...`); + await IosSimulatorUtils.deleteAsync({ + deviceIdentifier: localDeviceIdentifier as IosSimulatorName, + env, + }); + break; + } + case 'android': { + logger.info(`Collecting logs from ${localDeviceName}...`); + logsResult = await asyncResult( + AndroidEmulatorUtils.collectLogsAsync({ + serialId: localDeviceIdentifier as AndroidDeviceSerialId, + env, + }) + ); + + logger.info(`Cleaning up ${localDeviceName}...`); + await AndroidEmulatorUtils.deleteAsync({ + serialId: localDeviceIdentifier as AndroidDeviceSerialId, + env, + }); + break; + } + } + } catch (err) { + logger.error(`Error cleaning up device: ${err}`); + } + + return { fnResult: fnResult.enforceValue(), logsResult }; +} + +/** Runs provided `fn` function, optionally wrapping it with starting and stopping screen recording. */ +async function maybeWithScreenRecordingAsync({ + shouldRecord, + platform, + deviceIdentifier, + env, + logger, + fn, +}: { + // As weird as it is, it's more convenient to have this function like `maybeWith...` + // than "withScreenRecordingAsync" and `withScreenRecordingAsync(fn)` vs `fn` in the caller. + shouldRecord: boolean; + platform: 'ios' | 'android'; + deviceIdentifier: IosSimulatorName | AndroidDeviceSerialId; + env: BuildStepEnv; + logger: bunyan; + fn: () => Promise; +}): Promise<{ fnResult: Result; recordingResult: Result }> { + if (!shouldRecord) { + return { fnResult: await asyncResult(fn()), recordingResult: result(null) }; + } + + let recordingResult: Result<{ + recordingSpawn: SpawnPromise; + outputPath?: string; + }>; + + // Start screen recording + + logger.info(`Starting screen recording on ${deviceIdentifier}...`); + + switch (platform) { + case 'ios': { + recordingResult = await asyncResult( + IosSimulatorUtils.startScreenRecordingAsync({ + deviceIdentifier: deviceIdentifier as IosSimulatorName, + env, + }) + ); + break; + } + case 'android': { + recordingResult = await asyncResult( + AndroidEmulatorUtils.startScreenRecordingAsync({ + serialId: deviceIdentifier as AndroidDeviceSerialId, + env, + }) + ); + break; + } + } + + if (!recordingResult.ok) { + logger.warn('Failed to start screen recording.', recordingResult.reason); + } + + // Run the function + + const fnResult = await asyncResult(fn()); + + // If recording failed there's nothing to stop, so we return the results + + if (!recordingResult.ok) { + return { fnResult, recordingResult: result(recordingResult.reason) }; + } + + // If recording started, finish it + + try { + logger.info(`Stopping screen recording on ${deviceIdentifier}...`); + + switch (platform) { + case 'ios': { + await IosSimulatorUtils.stopScreenRecordingAsync({ + recordingSpawn: recordingResult.value.recordingSpawn, + }); + return { + fnResult, + // We know outputPath is defined, because startIosScreenRecording() should have filled it. + recordingResult: result(recordingResult.value.outputPath!), + }; + } + case 'android': { + const { outputPath } = await AndroidEmulatorUtils.stopScreenRecordingAsync({ + serialId: deviceIdentifier as AndroidDeviceSerialId, + recordingSpawn: recordingResult.value.recordingSpawn, + env, + }); + return { fnResult, recordingResult: result(outputPath) }; + } + } + } catch (err) { + logger.warn('Failed to stop screen recording.', err); + + return { fnResult, recordingResult: result(err as Error) }; + } +} diff --git a/packages/build-tools/src/steps/functions/prebuild.ts b/packages/build-tools/src/steps/functions/prebuild.ts new file mode 100644 index 0000000000..939dcf5e29 --- /dev/null +++ b/packages/build-tools/src/steps/functions/prebuild.ts @@ -0,0 +1,119 @@ +import { Platform } from '@expo/config'; +import { BuildFunction, BuildStepInput, BuildStepInputValueTypeName } from '@expo/steps'; +import spawn from '@expo/turtle-spawn'; + +import { PackageManager, resolvePackageManager } from '../../utils/packageManager'; + +import { installNodeModules } from './installNodeModules'; + +export function createPrebuildBuildFunction(): BuildFunction { + return new BuildFunction({ + namespace: 'eas', + id: 'prebuild', + name: 'Prebuild', + inputProviders: [ + BuildStepInput.createProvider({ + id: 'clean', + defaultValue: false, + allowedValueTypeName: BuildStepInputValueTypeName.BOOLEAN, + required: true, + }), + BuildStepInput.createProvider({ + id: 'apple_team_id', + allowedValueTypeName: BuildStepInputValueTypeName.STRING, + required: false, + }), + BuildStepInput.createProvider({ + id: 'platform', + allowedValueTypeName: BuildStepInputValueTypeName.STRING, + required: false, + }), + ], + fn: async (stepCtx, { inputs, env }) => { + const { logger } = stepCtx; + const appleTeamId = inputs.apple_team_id.value as string | undefined; + const packageManager = resolvePackageManager(stepCtx.workingDirectory); + const defaultPlatform = process.platform === 'darwin' ? 'ios' : 'android'; + + const job = stepCtx.global.staticContext.job; + const prebuildCommandArgs = getPrebuildCommandArgs({ + platform: job.platform ?? defaultPlatform, + customPrebuildCommand: job.platform ? job.experimental?.prebuildCommand : undefined, + clean: inputs.clean.value as boolean, + }); + const argsWithExpo = ['expo', ...prebuildCommandArgs]; + const options = { + cwd: stepCtx.workingDirectory, + logger, + env: { + EXPO_IMAGE_UTILS_NO_SHARP: '1', + ...env, + ...(appleTeamId ? { APPLE_TEAM_ID: appleTeamId } : {}), + }, + }; + if (packageManager === PackageManager.NPM) { + await spawn('npx', argsWithExpo, options); + } else if (packageManager === PackageManager.YARN) { + await spawn('yarn', argsWithExpo, options); + } else if (packageManager === PackageManager.PNPM) { + await spawn('pnpm', argsWithExpo, options); + } else if (packageManager === PackageManager.BUN) { + await spawn('bun', argsWithExpo, options); + } else { + throw new Error(`Unsupported package manager: ${packageManager}`); + } + await installNodeModules(stepCtx, env); + }, + }); +} + +function getPrebuildCommandArgs({ + platform, + customPrebuildCommand, + clean, +}: { + platform: Platform; + customPrebuildCommand?: string; + clean: boolean; +}): string[] { + if (customPrebuildCommand) { + return sanitizeUserDefinedPrebuildCommand({ + customPrebuildCommand, + platform, + clean, + }); + } + return ['prebuild', '--no-install', '--platform', platform, ...(clean ? ['--clean'] : [])]; +} + +// TODO: deprecate prebuildCommand in eas.json +function sanitizeUserDefinedPrebuildCommand({ + customPrebuildCommand, + platform, + clean, +}: { + customPrebuildCommand: string; + platform: Platform; + clean: boolean; +}): string[] { + let prebuildCommand = customPrebuildCommand; + if (!prebuildCommand.match(/(?:--platform| -p)/)) { + prebuildCommand = `${prebuildCommand} --platform ${platform}`; + } + if (clean) { + prebuildCommand = `${prebuildCommand} --clean`; + } + const npxCommandPrefix = 'npx '; + const expoCommandPrefix = 'expo '; + const expoCliCommandPrefix = 'expo-cli '; + if (prebuildCommand.startsWith(npxCommandPrefix)) { + prebuildCommand = prebuildCommand.substring(npxCommandPrefix.length).trim(); + } + if (prebuildCommand.startsWith(expoCommandPrefix)) { + prebuildCommand = prebuildCommand.substring(expoCommandPrefix.length).trim(); + } + if (prebuildCommand.startsWith(expoCliCommandPrefix)) { + prebuildCommand = prebuildCommand.substring(expoCliCommandPrefix.length).trim(); + } + return prebuildCommand.split(' '); +} diff --git a/packages/build-tools/src/steps/functions/repack.ts b/packages/build-tools/src/steps/functions/repack.ts new file mode 100644 index 0000000000..3a23bb90dc --- /dev/null +++ b/packages/build-tools/src/steps/functions/repack.ts @@ -0,0 +1,266 @@ +import { randomUUID } from 'node:crypto'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +import resolveFrom from 'resolve-from'; +import { Platform, type Android, type Ios, type Job } from '@expo/eas-build-job'; +import { type bunyan } from '@expo/logger'; +import { + BuildFunction, + BuildStepInput, + BuildStepInputValueTypeName, + BuildStepOutput, + spawnAsync, +} from '@expo/steps'; +import { + type AndroidSigningOptions, + type IosSigningOptions, + type SpawnProcessAsync, + type SpawnProcessOptions, + type SpawnProcessPromise, + type SpawnProcessResult, +} from '@expo/repack-app'; + +import { COMMON_FASTLANE_ENV } from '../../common/fastlane'; +import IosCredentialsManager from '../utils/ios/credentials/manager'; + +export function createRepackBuildFunction(): BuildFunction { + return new BuildFunction({ + namespace: 'eas', + id: 'repack', + name: 'Repack app', + inputProviders: [ + BuildStepInput.createProvider({ + id: 'source_app_path', + allowedValueTypeName: BuildStepInputValueTypeName.STRING, + required: true, + }), + BuildStepInput.createProvider({ + id: 'platform', + required: false, + allowedValueTypeName: BuildStepInputValueTypeName.STRING, + }), + BuildStepInput.createProvider({ + id: 'output_path', + allowedValueTypeName: BuildStepInputValueTypeName.STRING, + required: false, + }), + BuildStepInput.createProvider({ + id: 'embed_bundle_assets', + allowedValueTypeName: BuildStepInputValueTypeName.BOOLEAN, + required: false, + }), + BuildStepInput.createProvider({ + id: 'repack_version', + allowedValueTypeName: BuildStepInputValueTypeName.STRING, + required: false, + defaultValue: 'latest', + }), + ], + outputProviders: [ + BuildStepOutput.createProvider({ + id: 'output_path', + required: true, + }), + ], + fn: async (stepsCtx, { inputs, outputs, env }) => { + const projectRoot = stepsCtx.workingDirectory; + const verbose = stepsCtx.global.env['EAS_VERBOSE'] === '1'; + + const platform = + (inputs.platform.value as Platform) ?? stepsCtx.global.staticContext.job.platform; + if (![Platform.ANDROID, Platform.IOS].includes(platform)) { + throw new Error( + `Unsupported platform: ${platform}. Platform must be "${Platform.ANDROID}" or "${Platform.IOS}"` + ); + } + + const repackSpawnAsync = createSpawnAsyncStepAdapter({ verbose, logger: stepsCtx.logger }); + + const tmpDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), `repack-`)); + const workingDirectory = path.join(tmpDir, 'working-directory'); + await fs.promises.mkdir(workingDirectory); + stepsCtx.logger.info(`Created temporary working directory: ${workingDirectory}`); + + const sourceAppPath = inputs.source_app_path.value as string; + const outputPath = + (inputs.output_path.value as string) ?? + path.join(tmpDir, `repacked-${randomUUID()}${path.extname(sourceAppPath)}`); + const exportEmbedOptions = inputs.embed_bundle_assets.value + ? { + sourcemapOutput: undefined, + } + : undefined; + + stepsCtx.logger.info(`Using repack tool version: ${inputs.repack_version.value}`); + const repackApp = await installAndImportRepackAsync(inputs.repack_version.value as string); + const { repackAppIosAsync, repackAppAndroidAsync } = repackApp; + + stepsCtx.logger.info('Repacking the app...'); + switch (platform) { + case Platform.IOS: + await repackAppIosAsync({ + platform: 'ios', + projectRoot, + sourceAppPath, + outputPath, + workingDirectory, + exportEmbedOptions, + iosSigningOptions: await resolveIosSigningOptionsAsync({ + job: stepsCtx.global.staticContext.job, + logger: stepsCtx.logger, + }), + logger: stepsCtx.logger, + spawnAsync: repackSpawnAsync, + verbose, + env: { + ...COMMON_FASTLANE_ENV, + ...env, + }, + }); + break; + case Platform.ANDROID: + { + const androidSigningOptions = await resolveAndroidSigningOptionsAsync({ + job: stepsCtx.global.staticContext.job, + tmpDir, + }); + + try { + await repackAppAndroidAsync({ + platform: 'android', + projectRoot, + sourceAppPath, + outputPath, + workingDirectory, + exportEmbedOptions, + androidSigningOptions, + logger: stepsCtx.logger, + spawnAsync: repackSpawnAsync, + verbose, + env, + }); + } finally { + const keyStorePath = androidSigningOptions?.keyStorePath; + if (keyStorePath) { + await fs.promises.rm(keyStorePath, { force: true }); + } + } + } + break; + } + + stepsCtx.logger.info(`Repacked the app to ${outputPath}`); + outputs.output_path.set(outputPath); + }, + }); +} + +/** + * Install `@expo/repack-app` in a sandbox directory and import it. + */ +async function installAndImportRepackAsync( + version: string = 'latest' +): Promise { + const sandbox = await fs.promises.mkdtemp(path.join(os.tmpdir(), `repack-package-root-`)); + await spawnAsync('yarn', ['add', `@expo/repack-app@${version}`], { + stdio: 'inherit', + cwd: sandbox, + }); + return require(resolveFrom(sandbox, '@expo/repack-app')); +} + +/** + * Creates `@expo/steps` based spawnAsync for repack. + */ +function createSpawnAsyncStepAdapter({ + verbose, + logger, +}: { + verbose: boolean; + logger: bunyan; +}): SpawnProcessAsync { + return function repackSpawnAsync( + command: string, + args: string[], + options?: SpawnProcessOptions + ): SpawnProcessPromise { + const promise = spawnAsync(command, args, { + ...options, + ...(verbose ? { logger, stdio: 'pipe' } : { logger: undefined }), + }); + const child = promise.child; + const wrappedPromise = promise.catch((error) => { + logger.error(`Error while running command: ${command} ${args.join(' ')}`); + logger.error(`stdout: ${error.stdout}`); + logger.error(`stderr: ${error.stderr}`); + throw error; + }) as SpawnProcessPromise; + wrappedPromise.child = child; + return wrappedPromise; + }; +} + +/** + * Resolves Android signing options from the job secrets. + */ +export async function resolveAndroidSigningOptionsAsync({ + job, + tmpDir, +}: { + job: Job; + tmpDir: string; +}): Promise { + const androidJob = job as Android.Job; + const buildCredentials = androidJob.secrets?.buildCredentials; + if (buildCredentials?.keystore.dataBase64 == null) { + return undefined; + } + const keyStorePath = path.join(tmpDir, `keystore-${randomUUID()}`); + await fs.promises.writeFile( + keyStorePath, + new Uint8Array(Buffer.from(buildCredentials.keystore.dataBase64, 'base64')) + ); + + const keyStorePassword = `pass:${buildCredentials.keystore.keystorePassword}`; + const keyAlias = buildCredentials.keystore.keyAlias; + const keyPassword = buildCredentials.keystore.keyPassword + ? `pass:${buildCredentials.keystore.keyPassword}` + : undefined; + return { + keyStorePath, + keyStorePassword, + keyAlias, + keyPassword, + }; +} + +/** + * Resolves iOS signing options from the job secrets. + */ +export async function resolveIosSigningOptionsAsync({ + job, + logger, +}: { + job: Job; + logger: bunyan; +}): Promise { + const iosJob = job as Ios.Job; + const buildCredentials = iosJob.secrets?.buildCredentials; + if (iosJob.simulator || buildCredentials == null) { + return undefined; + } + const credentialsManager = new IosCredentialsManager(buildCredentials); + const credentials = await credentialsManager.prepare(logger); + + const provisioningProfile: Record = {}; + for (const profile of Object.values(credentials.targetProvisioningProfiles)) { + provisioningProfile[profile.bundleIdentifier] = profile.path; + } + return { + provisioningProfile, + keychainPath: credentials.keychainPath, + signingIdentity: credentials.applicationTargetProvisioningProfile.data.certificateCommonName, + }; +} diff --git a/packages/build-tools/src/steps/functions/resolveAppleTeamIdFromCredentials.ts b/packages/build-tools/src/steps/functions/resolveAppleTeamIdFromCredentials.ts new file mode 100644 index 0000000000..ab3da78962 --- /dev/null +++ b/packages/build-tools/src/steps/functions/resolveAppleTeamIdFromCredentials.ts @@ -0,0 +1,48 @@ +import { + BuildFunction, + BuildStepInput, + BuildStepInputValueTypeName, + BuildStepOutput, +} from '@expo/steps'; + +import IosCredentialsManager from '../utils/ios/credentials/manager'; +import { IosBuildCredentialsSchema } from '../utils/ios/credentials/credentials'; + +export function resolveAppleTeamIdFromCredentialsFunction(): BuildFunction { + return new BuildFunction({ + namespace: 'eas', + id: 'resolve_apple_team_id_from_credentials', + name: 'Resolve Apple team ID from credentials', + inputProviders: [ + BuildStepInput.createProvider({ + id: 'credentials', + required: true, + allowedValueTypeName: BuildStepInputValueTypeName.JSON, + defaultValue: '${ eas.job.secrets.buildCredentials }', + }), + ], + outputProviders: [ + BuildStepOutput.createProvider({ + id: 'apple_team_id', + required: true, + }), + ], + fn: async (stepCtx, { inputs, outputs }) => { + const rawCredentialsInput = inputs.credentials.value as Record; + const { value, error } = IosBuildCredentialsSchema.validate(rawCredentialsInput, { + stripUnknown: true, + convert: true, + abortEarly: false, + }); + if (error) { + throw error; + } + + const credentialsManager = new IosCredentialsManager(value); + const credentials = await credentialsManager.prepare(stepCtx.logger); + + stepCtx.logger.info(`Using Apple Team ID: ${credentials.teamId}`); + outputs.apple_team_id.set(credentials.teamId); + }, + }); +} diff --git a/packages/build-tools/src/steps/functions/resolveBuildConfig.ts b/packages/build-tools/src/steps/functions/resolveBuildConfig.ts new file mode 100644 index 0000000000..1a934c64ec --- /dev/null +++ b/packages/build-tools/src/steps/functions/resolveBuildConfig.ts @@ -0,0 +1,53 @@ +import { BuildJob, BuildTrigger } from '@expo/eas-build-job'; +import { bunyan } from '@expo/logger'; +import { BuildFunction, BuildStepEnv } from '@expo/steps'; +import { omit } from 'lodash'; + +import { runEasBuildInternalAsync } from '../../common/easBuildInternal'; +import { CustomBuildContext } from '../../customBuildContext'; + +export function createResolveBuildConfigBuildFunction( + ctx: CustomBuildContext +): BuildFunction { + return new BuildFunction({ + namespace: 'eas', + id: 'resolve_build_config', + name: 'Resolve build config', + fn: async ({ logger, workingDirectory }, { env }) => { + await resolveBuildConfigAsync({ logger, workingDirectory, env, ctx }); + }, + }); +} + +export async function resolveBuildConfigAsync({ + logger, + workingDirectory, + env, + ctx, +}: { + logger: bunyan; + workingDirectory: string; + env: BuildStepEnv; + ctx: CustomBuildContext; +}): Promise { + if (ctx.job.triggeredBy === BuildTrigger.GIT_BASED_INTEGRATION) { + logger.info('Resolving build config...'); + const { newJob, newMetadata } = await runEasBuildInternalAsync({ + job: ctx.job, + env, + logger, + cwd: workingDirectory, + projectRootOverride: env.EAS_NO_VCS ? ctx.projectTargetDirectory : undefined, + }); + ctx.updateJobInformation(newJob, newMetadata); + } + + logger.info('Build config resolved:'); + logger.info( + JSON.stringify( + { job: omit(ctx.job, 'secrets', 'projectArchive'), metadata: ctx.metadata }, + null, + 2 + ) + ); +} diff --git a/packages/build-tools/src/steps/functions/restoreBuildCache.ts b/packages/build-tools/src/steps/functions/restoreBuildCache.ts new file mode 100644 index 0000000000..3fdde0b7e3 --- /dev/null +++ b/packages/build-tools/src/steps/functions/restoreBuildCache.ts @@ -0,0 +1,199 @@ +import { + BuildFunction, + BuildStepInput, + BuildStepInputValueTypeName, + spawnAsync, +} from '@expo/steps'; +import { Platform } from '@expo/eas-build-job'; +import { bunyan } from '@expo/logger'; +import { asyncResult } from '@expo/results'; +import nullthrows from 'nullthrows'; + +import { + CACHE_KEY_PREFIX_BY_PLATFORM, + generateDefaultBuildCacheKeyAsync, + getCcachePath, +} from '../../utils/cacheKey'; +import { TurtleFetchError } from '../../utils/turtleFetch'; + +import { downloadCacheAsync, decompressCacheAsync, downloadPublicCacheAsync } from './restoreCache'; + +export function createRestoreBuildCacheFunction(): BuildFunction { + return new BuildFunction({ + namespace: 'eas', + id: 'restore_build_cache', + name: 'Restore Cache', + inputProviders: [ + BuildStepInput.createProvider({ + id: 'platform', + required: false, + allowedValueTypeName: BuildStepInputValueTypeName.STRING, + }), + ], + fn: async (stepCtx, { env, inputs }) => { + const { logger } = stepCtx; + const workingDirectory = stepCtx.workingDirectory; + const platform = + (inputs.platform.value as Platform | undefined) ?? + stepCtx.global.staticContext.job.platform; + if (!platform || ![Platform.ANDROID, Platform.IOS].includes(platform)) { + throw new Error( + `Unsupported platform: ${platform}. Platform must be "${Platform.ANDROID}" or "${Platform.IOS}"` + ); + } + + await restoreCcacheAsync({ + logger, + workingDirectory, + platform, + env, + secrets: stepCtx.global.staticContext.job.secrets, + }); + }, + }); +} + +export function createCacheStatsBuildFunction(): BuildFunction { + return new BuildFunction({ + namespace: 'eas', + id: 'cache_stats', + name: 'Cache Stats', + fn: async (stepCtx, { env }) => { + await cacheStatsAsync({ logger: stepCtx.logger, env }); + }, + }); +} + +export async function restoreCcacheAsync({ + logger, + workingDirectory, + platform, + env, + secrets, +}: { + logger: bunyan; + workingDirectory: string; + platform: Platform; + env: Record; + secrets?: { robotAccessToken?: string }; +}): Promise { + const enabled = + env.EAS_RESTORE_CACHE === '1' || (env.EAS_USE_CACHE === '1' && env.EAS_RESTORE_CACHE !== '0'); + + if (!enabled) { + return; + } + const robotAccessToken = nullthrows( + secrets?.robotAccessToken, + 'Robot access token is required for cache operations' + ); + const expoApiServerURL = nullthrows(env.__API_SERVER_URL, '__API_SERVER_URL is not set'); + const cachePath = getCcachePath(env); + + // Check if ccache is installed before proceeding + const checkInstall = await asyncResult( + spawnAsync('command', ['-v', 'ccache'], { + env, + stdio: 'pipe', + shell: true, + }) + ); + if (!checkInstall.ok) { + return; + } + + try { + // Zero ccache stats for accurate tracking + await asyncResult( + spawnAsync('ccache', ['--zero-stats'], { + env, + stdio: 'pipe', + }) + ); + + const cacheKey = await generateDefaultBuildCacheKeyAsync(workingDirectory, platform); + logger.info(`Restoring cache key: ${cacheKey}`); + + const jobId = nullthrows(env.EAS_BUILD_ID, 'EAS_BUILD_ID is not set'); + const { archivePath, matchedKey } = await downloadCacheAsync({ + logger, + jobId, + expoApiServerURL, + robotAccessToken, + paths: [cachePath], + key: cacheKey, + keyPrefixes: [CACHE_KEY_PREFIX_BY_PLATFORM[platform]], + platform, + }); + + await decompressCacheAsync({ + archivePath, + workingDirectory, + verbose: env.EXPO_DEBUG === '1', + logger, + }); + + logger.info( + `Cache restored successfully ${matchedKey === cacheKey ? '(direct hit)' : '(prefix match)'}` + ); + } catch (err: unknown) { + if (err instanceof TurtleFetchError && err.response?.status === 404) { + try { + logger.info('No cache found for this key. Downloading public cache...'); + const { archivePath } = await downloadPublicCacheAsync({ + logger, + expoApiServerURL, + robotAccessToken, + paths: [cachePath], + platform, + }); + await decompressCacheAsync({ + archivePath, + workingDirectory, + verbose: env.EXPO_DEBUG === '1', + logger, + }); + } catch (err: unknown) { + logger.warn({ err }, 'Failed to download public cache'); + } + } else { + logger.warn({ err }, 'Failed to restore cache'); + } + } +} + +export async function cacheStatsAsync({ + logger, + env, +}: { + logger: bunyan; + env: Record; +}): Promise { + const enabled = + env.EAS_RESTORE_CACHE === '1' || (env.EAS_USE_CACHE === '1' && env.EAS_RESTORE_CACHE !== '0'); + + if (!enabled) { + return; + } + + // Check if ccache is installed + const checkInstall = await asyncResult( + spawnAsync('command', ['-v', 'ccache'], { + env, + stdio: 'pipe', + shell: true, + }) + ); + if (!checkInstall.ok) { + return; + } + + logger.info('Cache stats:'); + await asyncResult( + spawnAsync('ccache', ['--show-stats', '-v'], { + env, + logger, + stdio: 'pipe', + }) + ); +} diff --git a/packages/build-tools/src/steps/functions/restoreCache.ts b/packages/build-tools/src/steps/functions/restoreCache.ts new file mode 100644 index 0000000000..69f0a4e17a --- /dev/null +++ b/packages/build-tools/src/steps/functions/restoreCache.ts @@ -0,0 +1,332 @@ +import fs from 'fs'; +import os from 'os'; +import path from 'path'; +import stream from 'stream'; +import { promisify } from 'util'; + +import * as tar from 'tar'; +import { bunyan } from '@expo/logger'; +import { + BuildFunction, + BuildStepInput, + BuildStepInputValueTypeName, + BuildStepOutput, +} from '@expo/steps'; +import z from 'zod'; +import nullthrows from 'nullthrows'; +import fetch from 'node-fetch'; +import { asyncResult } from '@expo/results'; +import { Platform } from '@expo/eas-build-job'; + +import { retryOnDNSFailure } from '../../utils/retryOnDNSFailure'; +import { formatBytes } from '../../utils/artifacts'; +import { getCacheVersion } from '../utils/cache'; +import { turtleFetch, TurtleFetchError } from '../../utils/turtleFetch'; +import { PUBLIC_CACHE_KEY_PREFIX_BY_PLATFORM } from '../../utils/cacheKey'; + +const streamPipeline = promisify(stream.pipeline); + +export function createRestoreCacheFunction(): BuildFunction { + return new BuildFunction({ + namespace: 'eas', + id: 'restore_cache', + name: 'Restore Cache', + inputProviders: [ + BuildStepInput.createProvider({ + id: 'path', + required: false, + allowedValueTypeName: BuildStepInputValueTypeName.STRING, + }), + BuildStepInput.createProvider({ + id: 'key', + required: true, + allowedValueTypeName: BuildStepInputValueTypeName.STRING, + }), + BuildStepInput.createProvider({ + id: 'restore_keys', + required: false, + allowedValueTypeName: BuildStepInputValueTypeName.STRING, + }), + ], + outputProviders: [ + BuildStepOutput.createProvider({ + id: 'cache_hit', + required: false, + }), + ], + fn: async (stepsCtx, { env, inputs, outputs }) => { + const { logger } = stepsCtx; + + try { + if (stepsCtx.global.staticContext.job.platform) { + logger.error('Caches are not supported in build jobs yet.'); + return; + } + + const paths = z + .array(z.string()) + .parse(((inputs.path.value ?? '') as string).split(/[\r\n]+/)) + .filter((path) => path.length > 0); + const key = z.string().parse(inputs.key.value); + const restoreKeys = z + .array(z.string()) + .parse(((inputs.restore_keys.value ?? '') as string).split(/[\r\n]+/)) + .filter((key) => key !== ''); + + const jobId = nullthrows(env.EAS_BUILD_ID, 'EAS_BUILD_ID is not set'); + const robotAccessToken = nullthrows( + stepsCtx.global.staticContext.job.secrets?.robotAccessToken, + 'robotAccessToken is not set' + ); + + const { archivePath, matchedKey } = await downloadCacheAsync({ + logger, + jobId, + expoApiServerURL: stepsCtx.global.staticContext.expoApiServerURL, + robotAccessToken, + paths, + key, + keyPrefixes: restoreKeys, + platform: stepsCtx.global.staticContext.job.platform, + }); + + const { size } = await fs.promises.stat(archivePath); + logger.info(`Downloaded cache archive from ${archivePath} (${formatBytes(size)}).`); + + await decompressCacheAsync({ + archivePath, + workingDirectory: stepsCtx.workingDirectory, + verbose: true, + logger, + }); + + outputs.cache_hit.set(`${matchedKey === key}`); + } catch (error) { + logger.error({ err: error }, 'Failed to restore cache'); + } + }, + }); +} + +export async function downloadCacheAsync({ + logger, + jobId, + expoApiServerURL, + robotAccessToken, + paths, + key, + keyPrefixes, + platform, +}: { + logger: bunyan; + jobId: string; + expoApiServerURL: string; + robotAccessToken: string; + paths: string[]; + key: string; + keyPrefixes: string[]; + platform: Platform | undefined; +}): Promise<{ archivePath: string; matchedKey: string }> { + const routerURL = platform ? 'v2/turtle-builds/caches/download' : 'v2/turtle-caches/download'; + + try { + const response = await turtleFetch(new URL(routerURL, expoApiServerURL).toString(), 'POST', { + json: platform + ? { + buildId: jobId, + key, + version: getCacheVersion(paths), + keyPrefixes, + } + : { + jobRunId: jobId, + key, + version: getCacheVersion(paths), + keyPrefixes, + }, + headers: { + Authorization: `Bearer ${robotAccessToken}`, + 'Content-Type': 'application/json', + }, + // It's ok to retry POST caches/download, because we're only retrying signing a download URL. + retries: 2, + shouldThrowOnNotOk: true, + }); + + const result = await asyncResult(response.json()); + if (!result.ok) { + throw new Error(`Unexpected response from server (${response.status}): ${result.reason}`); + } + + const { matchedKey, downloadUrl } = result.value.data; + + logger.info(`Matched cache key: ${matchedKey}. Downloading...`); + + const downloadDestinationDirectory = await fs.promises.mkdtemp( + path.join(os.tmpdir(), 'restore-cache-') + ); + + const downloadResponse = await retryOnDNSFailure(fetch)(downloadUrl); + if (!downloadResponse.ok) { + throw new Error( + `Unexpected response from cache server (${downloadResponse.status}): ${downloadResponse.statusText}` + ); + } + + // URL may contain percent-encoded characters, e.g. my%20file.apk + // this replaces all non-alphanumeric characters (excluding dot) with underscore + const archiveFilename = path + .basename(new URL(downloadUrl).pathname) + .replace(/([^a-z0-9.-]+)/gi, '_'); + const archivePath = path.join(downloadDestinationDirectory, archiveFilename); + + await streamPipeline(downloadResponse.body, fs.createWriteStream(archivePath)); + + return { archivePath, matchedKey }; + } catch (err: any) { + if (err instanceof TurtleFetchError && err.response.status !== 404) { + const textResult = await asyncResult(err.response.text()); + throw new Error( + `Unexpected response from server (${err.response.status}): ${textResult.value}` + ); + } + throw err; + } +} + +export async function downloadPublicCacheAsync({ + logger, + expoApiServerURL, + robotAccessToken, + paths, + platform, +}: { + logger: bunyan; + expoApiServerURL: string; + robotAccessToken: string; + paths: string[]; + platform: Platform; +}): Promise<{ archivePath: string; matchedKey: string }> { + const routerURL = 'v2/public-turtle-caches/download'; + const key = PUBLIC_CACHE_KEY_PREFIX_BY_PLATFORM[platform]; + + try { + const response = await turtleFetch(new URL(routerURL, expoApiServerURL).toString(), 'POST', { + json: { + key, + version: getCacheVersion(paths), + keyPrefixes: [key], + }, + headers: { + Authorization: `Bearer ${robotAccessToken}`, + 'Content-Type': 'application/json', + }, + retries: 2, + shouldThrowOnNotOk: true, + }); + + const result = await asyncResult(response.json()); + if (!result.ok) { + throw new Error(`Unexpected response from server (${response.status}): ${result.reason}`); + } + + const { matchedKey, downloadUrl } = result.value.data; + + logger.info(`Matched public cache key: ${matchedKey}. Downloading...`); + + const downloadDestinationDirectory = await fs.promises.mkdtemp( + path.join(os.tmpdir(), 'restore-cache-') + ); + + const downloadResponse = await retryOnDNSFailure(fetch)(downloadUrl); + if (!downloadResponse.ok) { + throw new Error( + `Unexpected response from cache server (${downloadResponse.status}): ${downloadResponse.statusText}` + ); + } + + const archiveFilename = path + .basename(new URL(downloadUrl).pathname) + .replace(/([^a-z0-9.-]+)/gi, '_'); + const archivePath = path.join(downloadDestinationDirectory, archiveFilename); + + await streamPipeline(downloadResponse.body, fs.createWriteStream(archivePath)); + + return { archivePath, matchedKey }; + } catch (err: any) { + if (err instanceof TurtleFetchError && err.response.status !== 404) { + const textResult = await asyncResult(err.response.text()); + throw new Error( + `Unexpected response from server (${err.response.status}): ${textResult.value}` + ); + } + throw err; + } +} + +export async function decompressCacheAsync({ + archivePath, + workingDirectory, + verbose, + logger, +}: { + archivePath: string; + workingDirectory: string; + verbose: boolean; + logger: bunyan; +}): Promise { + if (verbose) { + logger.info(`Extracting cache to ${workingDirectory}:`); + } + + // First, extract everything to the working directory + const extractedFiles: string[] = []; + + await tar.extract({ + file: archivePath, + cwd: workingDirectory, + onwarn: (code, message, data) => { + logger.warn({ code, data }, message); + }, + preservePaths: true, + onReadEntry: (entry) => { + extractedFiles.push(entry.path); + if (verbose) { + logger.info(`- ${entry.path}`); + } + }, + }); + + // Handle absolute paths that were prefixed with __absolute__ + for (const extractedPath of extractedFiles) { + if (extractedPath.startsWith('__absolute__/')) { + const originalAbsolutePath = extractedPath.slice('__absolute__'.length); + const currentPath = path.join(workingDirectory, extractedPath); + + try { + // Ensure the target directory exists + await fs.promises.mkdir(path.dirname(originalAbsolutePath), { recursive: true }); + + // Move the file to its original absolute location + await fs.promises.rename(currentPath, originalAbsolutePath); + + if (verbose) { + logger.info(`Moved ${extractedPath} to ${originalAbsolutePath}`); + } + } catch (error) { + logger.warn(`Failed to restore absolute path ${originalAbsolutePath}: ${error}`); + } + } + } + + // Clean up any remaining __absolute__ directories + const absoluteDir = path.join(workingDirectory, '__absolute__'); + if ( + await fs.promises + .access(absoluteDir) + .then(() => true) + .catch(() => false) + ) { + await fs.promises.rm(absoluteDir, { recursive: true, force: true }); + } +} diff --git a/packages/build-tools/src/steps/functions/runFastlane.ts b/packages/build-tools/src/steps/functions/runFastlane.ts new file mode 100644 index 0000000000..460bf6acbc --- /dev/null +++ b/packages/build-tools/src/steps/functions/runFastlane.ts @@ -0,0 +1,56 @@ +import { + BuildFunction, + BuildStepInput, + BuildStepInputValueTypeName, + BuildStepOutput, +} from '@expo/steps'; + +import { runFastlaneGym } from '../utils/ios/fastlane'; +import { BuildStatusText, BuildStepOutputName } from '../utils/slackMessageDynamicFields'; + +export function runFastlaneFunction(): BuildFunction { + return new BuildFunction({ + namespace: 'eas', + id: 'run_fastlane', + name: 'Run fastlane', + outputProviders: [ + BuildStepOutput.createProvider({ + id: BuildStepOutputName.STATUS_TEXT, + required: true, + }), + BuildStepOutput.createProvider({ + id: BuildStepOutputName.ERROR_TEXT, + required: false, + }), + ], + inputProviders: [ + BuildStepInput.createProvider({ + id: 'resolved_eas_update_runtime_version', + required: false, + allowedValueTypeName: BuildStepInputValueTypeName.STRING, + }), + ], + fn: async (stepCtx, { env, outputs, inputs }) => { + outputs[BuildStepOutputName.STATUS_TEXT].set(BuildStatusText.STARTED); + const resolvedEASUpdateRuntimeVersion = inputs.resolved_eas_update_runtime_version.value as + | string + | undefined; + try { + await runFastlaneGym({ + workingDir: stepCtx.workingDirectory, + env, + logger: stepCtx.logger, + buildLogsDirectory: stepCtx.global.buildLogsDirectory, + ...(resolvedEASUpdateRuntimeVersion + ? { extraEnv: { EXPO_UPDATES_FINGERPRINT_OVERRIDE: resolvedEASUpdateRuntimeVersion } } + : null), + }); + } catch (error) { + outputs[BuildStepOutputName.STATUS_TEXT].set(BuildStatusText.ERROR); + outputs[BuildStepOutputName.ERROR_TEXT].set((error as Error).toString()); + throw error; + } + outputs[BuildStepOutputName.STATUS_TEXT].set(BuildStatusText.SUCCESS); + }, + }); +} diff --git a/packages/build-tools/src/steps/functions/runGradle.ts b/packages/build-tools/src/steps/functions/runGradle.ts new file mode 100644 index 0000000000..02b5cc5bec --- /dev/null +++ b/packages/build-tools/src/steps/functions/runGradle.ts @@ -0,0 +1,75 @@ +import path from 'path'; +import assert from 'assert'; + +import { Platform } from '@expo/eas-build-job'; +import { + BuildFunction, + BuildStepInput, + BuildStepInputValueTypeName, + BuildStepOutput, +} from '@expo/steps'; + +import { resolveGradleCommand, runGradleCommand } from '../utils/android/gradle'; +import { BuildStatusText, BuildStepOutputName } from '../utils/slackMessageDynamicFields'; + +export function runGradleFunction(): BuildFunction { + return new BuildFunction({ + namespace: 'eas', + id: 'run_gradle', + name: 'Run gradle', + inputProviders: [ + BuildStepInput.createProvider({ + id: 'command', + required: false, + allowedValueTypeName: BuildStepInputValueTypeName.STRING, + }), + BuildStepInput.createProvider({ + id: 'resolved_eas_update_runtime_version', + required: false, + allowedValueTypeName: BuildStepInputValueTypeName.STRING, + }), + ], + outputProviders: [ + BuildStepOutput.createProvider({ + id: BuildStepOutputName.STATUS_TEXT, + required: true, + }), + BuildStepOutput.createProvider({ + id: BuildStepOutputName.ERROR_TEXT, + required: false, + }), + ], + fn: async (stepCtx, { env, inputs, outputs }) => { + outputs[BuildStepOutputName.STATUS_TEXT].set(BuildStatusText.STARTED); + assert(stepCtx.global.staticContext.job, 'Job is required'); + assert( + stepCtx.global.staticContext.job.platform === Platform.ANDROID, + 'This function is only available when building for Android' + ); + const command = resolveGradleCommand( + stepCtx.global.staticContext.job, + inputs.command.value as string | undefined + ); + + const resolvedEASUpdateRuntimeVersion = inputs.resolved_eas_update_runtime_version.value as + | string + | undefined; + try { + await runGradleCommand({ + logger: stepCtx.logger, + gradleCommand: command, + androidDir: path.join(stepCtx.workingDirectory, 'android'), + env, + ...(resolvedEASUpdateRuntimeVersion + ? { extraEnv: { EXPO_UPDATES_FINGERPRINT_OVERRIDE: resolvedEASUpdateRuntimeVersion } } + : null), + }); + } catch (error) { + outputs[BuildStepOutputName.STATUS_TEXT].set(BuildStatusText.ERROR); + outputs[BuildStepOutputName.ERROR_TEXT].set((error as Error).toString()); + throw error; + } + outputs[BuildStepOutputName.STATUS_TEXT].set(BuildStatusText.SUCCESS); + }, + }); +} diff --git a/packages/build-tools/src/steps/functions/saveBuildCache.ts b/packages/build-tools/src/steps/functions/saveBuildCache.ts new file mode 100644 index 0000000000..f1dfd8ac4e --- /dev/null +++ b/packages/build-tools/src/steps/functions/saveBuildCache.ts @@ -0,0 +1,137 @@ +import fs from 'fs'; + +import { + BuildFunction, + BuildStepInput, + BuildStepInputValueTypeName, + spawnAsync, +} from '@expo/steps'; +import { Platform } from '@expo/eas-build-job'; +import { bunyan } from '@expo/logger'; +import { asyncResult } from '@expo/results'; +import nullthrows from 'nullthrows'; + +import { generateDefaultBuildCacheKeyAsync, getCcachePath } from '../../utils/cacheKey'; + +import { compressCacheAsync, uploadCacheAsync } from './saveCache'; + +export function createSaveBuildCacheFunction(evictUsedBefore: Date): BuildFunction { + return new BuildFunction({ + namespace: 'eas', + id: 'save_build_cache', + name: 'Save Cache', + inputProviders: [ + BuildStepInput.createProvider({ + id: 'platform', + required: false, + allowedValueTypeName: BuildStepInputValueTypeName.STRING, + }), + ], + fn: async (stepCtx, { env, inputs }) => { + const { logger } = stepCtx; + const workingDirectory = stepCtx.workingDirectory; + const platform = + (inputs.platform.value as Platform | undefined) ?? + stepCtx.global.staticContext.job.platform; + if (!platform || ![Platform.ANDROID, Platform.IOS].includes(platform)) { + throw new Error( + `Unsupported platform: ${platform}. Platform must be "${Platform.ANDROID}" or "${Platform.IOS}"` + ); + } + + await saveCcacheAsync({ + logger, + workingDirectory, + platform, + evictUsedBefore, + env, + secrets: stepCtx.global.staticContext.job.secrets, + }); + }, + }); +} + +export async function saveCcacheAsync({ + logger, + workingDirectory, + platform, + evictUsedBefore, + env, + secrets, +}: { + logger: bunyan; + workingDirectory: string; + platform: Platform; + evictUsedBefore: Date; + env: Record; + secrets?: { robotAccessToken?: string }; +}): Promise { + const enabled = + env.EAS_SAVE_CACHE === '1' || (env.EAS_USE_CACHE === '1' && env.EAS_SAVE_CACHE !== '0'); + + if (!enabled) { + return; + } + + // Check if ccache is installed before proceeding + const checkInstall = await asyncResult( + spawnAsync('command', ['-v', 'ccache'], { + env, + stdio: 'pipe', + shell: true, + }) + ); + if (!checkInstall.ok) { + return; + } + + try { + const cacheKey = await generateDefaultBuildCacheKeyAsync(workingDirectory, platform); + logger.info(`Saving cache key: ${cacheKey}`); + + const jobId = nullthrows(env.EAS_BUILD_ID, 'EAS_BUILD_ID is not set'); + const robotAccessToken = nullthrows( + secrets?.robotAccessToken, + 'Robot access token is required for cache operations' + ); + const expoApiServerURL = nullthrows(env.__API_SERVER_URL, '__API_SERVER_URL is not set'); + const cachePath = getCcachePath(env); + + // Cache size can blow up over time over many builds, so evict stale files + // and only upload what was used within this build's time window + const evictWindow = Math.floor((Date.now() - evictUsedBefore.getTime()) / 1000); + logger.info('Pruning cache...'); + await asyncResult( + spawnAsync('ccache', ['--evict-older-than', evictWindow + 's'], { + env, + logger, + stdio: 'pipe', + }) + ); + + logger.info('Preparing cache archive...'); + + const { archivePath } = await compressCacheAsync({ + paths: [cachePath], + workingDirectory, + verbose: env.EXPO_DEBUG === '1', + logger, + }); + + const { size } = await fs.promises.stat(archivePath); + + await uploadCacheAsync({ + logger, + jobId, + expoApiServerURL, + robotAccessToken, + archivePath, + key: cacheKey, + paths: [cachePath], + size, + platform, + }); + } catch (err) { + logger.error({ err }, 'Failed to save cache'); + } +} diff --git a/packages/build-tools/src/steps/functions/saveCache.ts b/packages/build-tools/src/steps/functions/saveCache.ts new file mode 100644 index 0000000000..537c7b1f7b --- /dev/null +++ b/packages/build-tools/src/steps/functions/saveCache.ts @@ -0,0 +1,368 @@ +import fs from 'fs'; +import os from 'os'; +import path from 'path'; + +import * as tar from 'tar'; +import fg from 'fast-glob'; +import { bunyan } from '@expo/logger'; +import { BuildFunction, BuildStepInput, BuildStepInputValueTypeName } from '@expo/steps'; +import z from 'zod'; +import nullthrows from 'nullthrows'; +import fetch from 'node-fetch'; +import { asyncResult } from '@expo/results'; +import { Platform } from '@expo/eas-build-job'; + +import { retryOnDNSFailure } from '../../utils/retryOnDNSFailure'; +import { formatBytes } from '../../utils/artifacts'; +import { getCacheVersion } from '../utils/cache'; + +export function createSaveCacheFunction(): BuildFunction { + return new BuildFunction({ + namespace: 'eas', + id: 'save_cache', + name: 'Save Cache', + inputProviders: [ + BuildStepInput.createProvider({ + id: 'path', + required: false, + allowedValueTypeName: BuildStepInputValueTypeName.STRING, + }), + BuildStepInput.createProvider({ + id: 'key', + required: true, + allowedValueTypeName: BuildStepInputValueTypeName.STRING, + }), + ], + fn: async (stepsCtx, { env, inputs }) => { + const { logger } = stepsCtx; + + try { + const paths = z + .array(z.string()) + .parse(((inputs.path.value ?? '') as string).split(/[\r\n]+/)) + .filter((path) => path.length > 0); + const key = z.string().parse(inputs.key.value); + const jobId = nullthrows(env.EAS_BUILD_ID, 'EAS_BUILD_ID is not set'); + const robotAccessToken = nullthrows( + stepsCtx.global.staticContext.job.secrets?.robotAccessToken, + 'robotAccessToken is not set' + ); + + const { archivePath } = await compressCacheAsync({ + paths, + workingDirectory: stepsCtx.workingDirectory, + verbose: true, + logger, + }); + + const { size } = await fs.promises.stat(archivePath); + + if (env.EAS_PUBLIC_CACHE === '1') { + await uploadPublicCacheAsync({ + logger, + jobId, + expoApiServerURL: stepsCtx.global.staticContext.expoApiServerURL, + robotAccessToken, + archivePath, + key, + paths, + size, + platform: stepsCtx.global.staticContext.job.platform, + }); + } else { + await uploadCacheAsync({ + logger, + jobId, + expoApiServerURL: stepsCtx.global.staticContext.expoApiServerURL, + robotAccessToken, + archivePath, + key, + paths, + size, + platform: stepsCtx.global.staticContext.job.platform, + }); + } + } catch (error) { + logger.error({ err: error }, 'Failed to create cache'); + } + }, + }); +} + +export async function uploadCacheAsync({ + logger, + jobId, + expoApiServerURL, + robotAccessToken, + paths, + key, + archivePath, + size, + platform, +}: { + logger: bunyan; + jobId: string; + expoApiServerURL: string; + robotAccessToken: string; + paths: string[]; + key: string; + archivePath: string; + size: number; + platform: Platform | undefined; +}): Promise { + const routerURL = platform + ? 'v2/turtle-builds/caches/upload-sessions' + : 'v2/turtle-caches/upload-sessions'; + + // attempts to upload should only attempt on DNS errors, and not application errors such as 409 (cache exists) + const response = await retryOnDNSFailure(fetch)(new URL(routerURL, expoApiServerURL), { + method: 'POST', + body: platform + ? JSON.stringify({ + buildId: jobId, + key, + version: getCacheVersion(paths), + size, + }) + : JSON.stringify({ + jobRunId: jobId, + key, + version: getCacheVersion(paths), + size, + }), + headers: { + Authorization: `Bearer ${robotAccessToken}`, + 'Content-Type': 'application/json', + }, + }); + if (!response.ok) { + if (response.status === 409) { + logger.info(`Cache already exists, skipping upload`); + return; + } + const textResult = await asyncResult(response.text()); + throw new Error(`Unexpected response from server (${response.status}): ${textResult.value}`); + } + + const result = await asyncResult(response.json()); + if (!result.ok) { + throw new Error(`Unexpected response from server (${response.status}): ${result.reason}`); + } + + const { url, headers } = result.value.data; + + logger.info(`Uploading cache...`); + + const uploadResponse = await retryOnDNSFailure(fetch)(new URL(url), { + method: 'PUT', + headers, + body: fs.createReadStream(archivePath), + }); + if (!uploadResponse.ok) { + throw new Error( + `Unexpected response from cache server (${uploadResponse.status}): ${uploadResponse.statusText}` + ); + } + logger.info(`Uploaded cache archive to ${archivePath} (${formatBytes(size)}).`); +} + +export async function uploadPublicCacheAsync({ + logger, + jobId, + expoApiServerURL, + robotAccessToken, + paths, + key, + archivePath, + size, +}: { + logger: bunyan; + jobId: string; + expoApiServerURL: string; + robotAccessToken: string; + paths: string[]; + key: string; + archivePath: string; + size: number; + platform: Platform | undefined; +}): Promise { + const routerPath = 'v2/public-turtle-caches/upload-sessions'; + const response = await retryOnDNSFailure(fetch)(new URL(routerPath, expoApiServerURL), { + method: 'POST', + body: JSON.stringify({ + jobRunId: jobId, + key, + version: getCacheVersion(paths), + size, + }), + headers: { + Authorization: `Bearer ${robotAccessToken}`, + 'Content-Type': 'application/json', + }, + }); + if (!response.ok) { + if (response.status === 409) { + logger.info(`Cache ${key} already exists, skipping upload`); + return; + } + const textResult = await asyncResult(response.text()); + throw new Error(`Unexpected response from server (${response.status}): ${textResult.value}`); + } + + const result = await asyncResult(response.json()); + if (!result.ok) { + throw new Error(`Unexpected response from server (${response.status}): ${result.reason}`); + } + + const { url, headers } = result.value.data; + + logger.info(`Uploading public cache...`); + + const uploadResponse = await retryOnDNSFailure(fetch)(new URL(url), { + method: 'PUT', + headers, + body: fs.createReadStream(archivePath), + }); + if (!uploadResponse.ok) { + throw new Error( + `Unexpected response from cache server (${uploadResponse.status}): ${uploadResponse.statusText}` + ); + } + logger.info(`Uploaded cache archive to ${archivePath} (${formatBytes(size)}).`); +} + +export async function compressCacheAsync({ + paths, + workingDirectory, + verbose, + logger, +}: { + paths: string[]; + workingDirectory: string; + verbose: boolean; + logger: bunyan; +}): Promise<{ archivePath: string }> { + const archiveDestinationDirectory = await fs.promises.mkdtemp( + path.join(os.tmpdir(), 'save-cache-') + ); + + // Process and normalize all paths + const allFiles: { absolutePath: string; archivePath: string }[] = []; + + for (const inputPath of paths) { + // Resolve to absolute path + const absolutePath = path.isAbsolute(inputPath) + ? inputPath + : path.resolve(workingDirectory, inputPath); + + try { + const stat = await fs.promises.stat(absolutePath); + + if (stat.isDirectory()) { + // For directories, get all files recursively + const pattern = fg.isDynamicPattern(inputPath) ? inputPath : `${absolutePath}/**`; + const dirFiles = await fg(pattern, { + absolute: true, + onlyFiles: true, + cwd: fg.isDynamicPattern(inputPath) ? workingDirectory : undefined, + }); + + for (const filePath of dirFiles) { + // Calculate the archive path + let archivePath: string; + + if (path.isAbsolute(inputPath)) { + // For absolute input paths, check if they're within workingDirectory + const relativeToWorkdir = path.relative(workingDirectory, filePath); + if (!relativeToWorkdir.startsWith('..') && !path.isAbsolute(relativeToWorkdir)) { + // File is within working directory - use relative path + archivePath = relativeToWorkdir; + } else { + // File is outside working directory - preserve relative structure from original path + const relativeToInput = path.relative(absolutePath, filePath); + archivePath = path.posix.join('__absolute__' + inputPath, relativeToInput); + } + } else { + // For relative input paths, maintain relative structure + archivePath = path.relative(workingDirectory, filePath); + } + + allFiles.push({ absolutePath: filePath, archivePath }); + } + } else { + // Single file + let archivePath: string; + + if (path.isAbsolute(inputPath)) { + const relativeToWorkdir = path.relative(workingDirectory, absolutePath); + if (!relativeToWorkdir.startsWith('..') && !path.isAbsolute(relativeToWorkdir)) { + archivePath = relativeToWorkdir; + } else { + archivePath = '__absolute__' + inputPath; + } + } else { + archivePath = inputPath; + } + + allFiles.push({ absolutePath, archivePath }); + } + } catch (error) { + logger.warn({ error }, 'Failed to resolve paths'); + // Handle glob patterns + if (fg.isDynamicPattern(inputPath)) { + const globFiles = await fg(inputPath, { + absolute: true, + cwd: workingDirectory, + onlyFiles: true, + }); + + for (const filePath of globFiles) { + const archivePath = path.relative(workingDirectory, filePath); + allFiles.push({ absolutePath: filePath, archivePath }); + } + } else { + throw new Error(`Path does not exist: ${inputPath}`); + } + } + } + + if (allFiles.length === 0) { + throw new Error('No files found to cache'); + } + + const archivePath = path.join(archiveDestinationDirectory, 'cache.tar.gz'); + + if (verbose) { + logger.info(`Compressing cache with ${allFiles.length} files:`); + } + + // Create a temporary directory with the correct structure + const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'cache-temp-')); + + try { + // Copy all files to temp directory maintaining archive structure + for (const { absolutePath, archivePath: targetRelativePath } of allFiles) { + const targetPath = path.join(tempDir, targetRelativePath); + await fs.promises.mkdir(path.dirname(targetPath), { recursive: true }); + await fs.promises.copyFile(absolutePath, targetPath); + + if (verbose) { + logger.info(`- ${targetRelativePath}`); + } + } + + // Create tar archive from the structured temp directory + await tar.c( + { + gzip: true, + file: archivePath, + cwd: tempDir, + }, + allFiles.map(({ archivePath: targetPath }) => targetPath) + ); + } finally { + await fs.promises.rm(tempDir, { recursive: true, force: true }); + } + + return { archivePath }; +} diff --git a/packages/build-tools/src/steps/functions/sendSlackMessage.ts b/packages/build-tools/src/steps/functions/sendSlackMessage.ts new file mode 100644 index 0000000000..fe580423b8 --- /dev/null +++ b/packages/build-tools/src/steps/functions/sendSlackMessage.ts @@ -0,0 +1,87 @@ +import { BuildFunction, BuildStepInput, BuildStepInputValueTypeName } from '@expo/steps'; +import fetch, { Response } from 'node-fetch'; +import { bunyan } from '@expo/logger'; + +export function createSendSlackMessageFunction(): BuildFunction { + return new BuildFunction({ + namespace: 'eas', + id: 'send_slack_message', + name: 'Send Slack message', + inputProviders: [ + BuildStepInput.createProvider({ + id: 'message', + allowedValueTypeName: BuildStepInputValueTypeName.STRING, + required: false, + }), + BuildStepInput.createProvider({ + id: 'payload', + allowedValueTypeName: BuildStepInputValueTypeName.JSON, + required: false, + }), + BuildStepInput.createProvider({ + id: 'slack_hook_url', + allowedValueTypeName: BuildStepInputValueTypeName.STRING, + required: false, + }), + ], + fn: async (stepCtx, { inputs, env }) => { + const { logger } = stepCtx; + const slackMessage = inputs.message.value as string; + const slackPayload = inputs.payload.value as object; + if (!slackMessage && !slackPayload) { + throw new Error( + 'You need to provide either "message" input or "payload" input to specify the Slack message contents' + ); + } + if (slackMessage && slackPayload) { + throw new Error( + 'You cannot specify both "message" input and "payload" input - choose one for the Slack message contents' + ); + } + const slackHookUrl = (inputs.slack_hook_url.value as string) ?? env.SLACK_HOOK_URL; + if (!slackHookUrl) { + logger.warn( + 'Slack webhook URL not provided - provide input "slack_hook_url" or set "SLACK_HOOK_URL" secret' + ); + throw new Error( + 'Sending Slack message failed - provide input "slack_hook_url" or set "SLACK_HOOK_URL" secret' + ); + } + await sendSlackMessageAsync({ logger, slackHookUrl, slackMessage, slackPayload }); + }, + }); +} + +async function sendSlackMessageAsync({ + logger, + slackHookUrl, + slackMessage, + slackPayload, +}: { + logger: bunyan; + slackHookUrl: string; + slackMessage: string | undefined; + slackPayload: object | undefined; +}): Promise { + logger.info('Sending Slack message'); + + const body = slackPayload ? slackPayload : { text: slackMessage }; + let fetchResult: Response; + try { + fetchResult = await fetch(slackHookUrl, { + method: 'POST', + body: JSON.stringify(body), + headers: { 'Content-Type': 'application/json' }, + }); + } catch (error) { + logger.debug(error); + throw new Error(`Sending Slack message to webhook url "${slackHookUrl}" failed`); + } + if (!fetchResult.ok) { + logger.debug(`${fetchResult.status} - ${fetchResult.statusText}`); + throw new Error( + `Sending Slack message to webhook url "${slackHookUrl}" failed with status ${fetchResult.status}` + ); + } + logger.info('Slack message sent successfully'); +} diff --git a/packages/build-tools/src/steps/functions/startAndroidEmulator.ts b/packages/build-tools/src/steps/functions/startAndroidEmulator.ts new file mode 100644 index 0000000000..66a9d1c37c --- /dev/null +++ b/packages/build-tools/src/steps/functions/startAndroidEmulator.ts @@ -0,0 +1,132 @@ +import { BuildFunction, BuildStepInput, BuildStepInputValueTypeName } from '@expo/steps'; +import spawn from '@expo/turtle-spawn'; +import { asyncResult } from '@expo/results'; + +import { retryAsync } from '../../utils/retry'; +import { + AndroidDeviceName, + AndroidEmulatorUtils, + AndroidVirtualDeviceName, +} from '../../utils/AndroidEmulatorUtils'; + +export function createStartAndroidEmulatorBuildFunction(): BuildFunction { + return new BuildFunction({ + namespace: 'eas', + id: 'start_android_emulator', + name: 'Start Android Emulator', + inputProviders: [ + BuildStepInput.createProvider({ + id: 'device_name', + required: false, + defaultValue: 'EasAndroidDevice01', + allowedValueTypeName: BuildStepInputValueTypeName.STRING, + }), + BuildStepInput.createProvider({ + id: 'device_identifier', + required: false, + allowedValueTypeName: BuildStepInputValueTypeName.STRING, + }), + BuildStepInput.createProvider({ + id: 'system_image_package', + required: false, + defaultValue: AndroidEmulatorUtils.defaultSystemImagePackage, + allowedValueTypeName: BuildStepInputValueTypeName.STRING, + }), + BuildStepInput.createProvider({ + id: 'count', + required: false, + defaultValue: 1, + allowedValueTypeName: BuildStepInputValueTypeName.NUMBER, + }), + ], + fn: async ({ logger }, { inputs, env }) => { + try { + const availableDevices = await AndroidEmulatorUtils.getAvailableDevicesAsync({ env }); + logger.info(`Available Android devices:\n- ${availableDevices.join(`\n- `)}`); + } catch (error) { + logger.info('Failed to list available Android devices.', error); + } finally { + logger.info(''); + } + + const deviceName = `${inputs.device_name.value}` as AndroidVirtualDeviceName; + const systemImagePackage = `${inputs.system_image_package.value}`; + // We can cast because allowedValueTypeName validated this is a string. + const deviceIdentifier = inputs.device_identifier.value as AndroidDeviceName | undefined; + + logger.info('Making sure system image is installed'); + await retryAsync( + async () => { + await spawn('sdkmanager', [systemImagePackage], { + env, + logger, + }); + }, + { + logger, + retryOptions: { + retries: 3, // Retry 3 times + retryIntervalMs: 1_000, + }, + } + ); + + logger.info('Creating emulator device'); + await AndroidEmulatorUtils.createAsync({ + deviceName, + systemImagePackage, + deviceIdentifier: deviceIdentifier ?? null, + env, + logger, + }); + + logger.info('Starting emulator device'); + const { emulatorPromise, serialId } = await AndroidEmulatorUtils.startAsync({ + deviceName, + env, + }); + await AndroidEmulatorUtils.waitForReadyAsync({ + env, + serialId, + }); + logger.info(`${deviceName} is ready.`); + + const count = Number(inputs.count.value ?? 1); + if (count > 1) { + logger.info(`Requested ${count} emulators, shutting down ${deviceName} for cloning.`); + await spawn('adb', ['-s', serialId, 'shell', 'reboot', '-p'], { + logger, + env, + }); + // Waiting for source emulator to shutdown. + // We don't care about resolved/rejected. + await asyncResult(emulatorPromise); + + for (let i = 0; i < count; i++) { + const cloneIdentifier = `eas-simulator-${i + 1}` as AndroidVirtualDeviceName; + logger.info(`Cloning ${deviceName} to ${cloneIdentifier}...`); + await AndroidEmulatorUtils.cloneAsync({ + sourceDeviceName: deviceName, + destinationDeviceName: cloneIdentifier, + env, + logger, + }); + + logger.info('Starting emulator device'); + const { serialId } = await AndroidEmulatorUtils.startAsync({ + deviceName: cloneIdentifier, + env, + }); + + logger.info('Waiting for emulator to become ready'); + await AndroidEmulatorUtils.waitForReadyAsync({ + serialId, + env, + }); + + logger.info(`${cloneIdentifier} is ready.`); + } + } + }, + }); +} diff --git a/packages/build-tools/src/steps/functions/startIosSimulator.ts b/packages/build-tools/src/steps/functions/startIosSimulator.ts new file mode 100644 index 0000000000..5d93682574 --- /dev/null +++ b/packages/build-tools/src/steps/functions/startIosSimulator.ts @@ -0,0 +1,126 @@ +import { + BuildFunction, + BuildStepEnv, + BuildStepInput, + BuildStepInputValueTypeName, +} from '@expo/steps'; +import spawn from '@expo/turtle-spawn'; +import { minBy } from 'lodash'; + +import { + IosSimulatorName, + IosSimulatorUtils, + IosSimulatorUuid, +} from '../../utils/IosSimulatorUtils'; + +export function createStartIosSimulatorBuildFunction(): BuildFunction { + return new BuildFunction({ + namespace: 'eas', + id: 'start_ios_simulator', + name: 'Start iOS Simulator', + inputProviders: [ + BuildStepInput.createProvider({ + id: 'device_identifier', + required: false, + allowedValueTypeName: BuildStepInputValueTypeName.STRING, + }), + BuildStepInput.createProvider({ + id: 'count', + required: false, + defaultValue: 1, + allowedValueTypeName: BuildStepInputValueTypeName.NUMBER, + }), + ], + fn: async ({ logger }, { inputs, env }) => { + try { + const availableDevices = await IosSimulatorUtils.getAvailableDevicesAsync({ + env, + filter: 'available', + }); + logger.info( + `Available Simulator devices:\n- ${availableDevices + .map((device) => device.displayName) + .join(`\n- `)}` + ); + } catch (error) { + logger.info('Failed to list available Simulator devices.', error); + } finally { + logger.info(''); + } + + const deviceIdentifierInput = inputs.device_identifier.value?.toString() as + | IosSimulatorUuid + | IosSimulatorName + | undefined; + const originalDeviceIdentifier = + deviceIdentifierInput ?? (await findMostGenericIphoneUuidAsync({ env })); + + if (!originalDeviceIdentifier) { + throw new Error('Could not find an iPhone among available simulator devices.'); + } + + const { udid } = await IosSimulatorUtils.startAsync({ + deviceIdentifier: originalDeviceIdentifier, + env, + }); + + await IosSimulatorUtils.waitForReadyAsync({ udid, env }); + + logger.info(''); + + const device = await IosSimulatorUtils.getDeviceAsync({ udid, env }); + const formattedDevice = device?.displayName ?? originalDeviceIdentifier; + logger.info(`${formattedDevice} is ready.`); + + const count = Number(inputs.count.value ?? 1); + if (count > 1) { + logger.info(`Requested ${count} Simulators, shutting down ${formattedDevice} for cloning.`); + await spawn('xcrun', ['simctl', 'shutdown', originalDeviceIdentifier], { + logger, + env, + }); + + for (let i = 0; i < count; i++) { + const cloneDeviceName = `eas-simulator-${i + 1}` as IosSimulatorName; + logger.info(`Cloning ${formattedDevice} to ${cloneDeviceName}...`); + + await IosSimulatorUtils.cloneAsync({ + sourceDeviceIdentifier: originalDeviceIdentifier, + destinationDeviceName: cloneDeviceName, + env, + }); + + const { udid: cloneUdid } = await IosSimulatorUtils.startAsync({ + deviceIdentifier: cloneDeviceName, + env, + }); + + await IosSimulatorUtils.waitForReadyAsync({ + udid: cloneUdid, + env, + }); + + logger.info(`${cloneDeviceName} is ready.`); + logger.info(''); + } + } + }, + }); +} + +async function findMostGenericIphoneUuidAsync({ + env, +}: { + env: BuildStepEnv; +}): Promise { + const availableSimulatorDevices = await IosSimulatorUtils.getAvailableDevicesAsync({ + env, + filter: 'available', + }); + const availableIphones = availableSimulatorDevices.filter((device) => + device.name.startsWith('iPhone') + ); + // It's funny, but it works. + const iphoneWithShortestName = minBy(availableIphones, (device) => device.name.length); + return iphoneWithShortestName?.udid ?? null; +} diff --git a/packages/build-tools/src/steps/functions/uploadArtifact.ts b/packages/build-tools/src/steps/functions/uploadArtifact.ts new file mode 100644 index 0000000000..9ed469eead --- /dev/null +++ b/packages/build-tools/src/steps/functions/uploadArtifact.ts @@ -0,0 +1,171 @@ +import assert from 'assert'; + +import { GenericArtifactType, Job, ManagedArtifactType } from '@expo/eas-build-job'; +import { + BuildFunction, + BuildStepInput, + BuildStepInputValueTypeName, + BuildStepOutput, +} from '@expo/steps'; + +import { CustomBuildContext } from '../../customBuildContext'; +import { FindArtifactsError, findArtifacts } from '../../utils/artifacts'; + +const artifactTypeInputToManagedArtifactType: Record = { + 'application-archive': ManagedArtifactType.APPLICATION_ARCHIVE, + 'build-artifact': ManagedArtifactType.BUILD_ARTIFACTS, +}; + +export function createUploadArtifactBuildFunction(ctx: CustomBuildContext): BuildFunction { + return new BuildFunction({ + namespace: 'eas', + id: 'upload_artifact', + name: 'Upload artifact', + inputProviders: [ + BuildStepInput.createProvider({ + id: 'type', + allowedValues: [ + ManagedArtifactType.APPLICATION_ARCHIVE, + ManagedArtifactType.BUILD_ARTIFACTS, + ...Object.keys(artifactTypeInputToManagedArtifactType), + ...Object.values(GenericArtifactType), + ], + required: false, + allowedValueTypeName: BuildStepInputValueTypeName.STRING, + }), + BuildStepInput.createProvider({ + id: 'key', + defaultValue: '', + required: false, + allowedValueTypeName: BuildStepInputValueTypeName.STRING, + }), + BuildStepInput.createProvider({ + id: 'name', + defaultValue: '', + required: false, + allowedValueTypeName: BuildStepInputValueTypeName.STRING, + }), + /** + * path inputs expects a list of newline-delimited search paths. + * Valid examples include: + * - path: app/artifact.app + * - path: app/*.app + * - path: | + * assets/*.png + * assets/*.jpg + * public/another-photo.jpg + */ + BuildStepInput.createProvider({ + id: 'path', + required: true, + allowedValueTypeName: BuildStepInputValueTypeName.STRING, + }), + BuildStepInput.createProvider({ + id: 'ignore_error', + required: false, + allowedValueTypeName: BuildStepInputValueTypeName.BOOLEAN, + }), + ], + outputProviders: [ + BuildStepOutput.createProvider({ + id: 'artifact_id', + required: false, + }), + ], + fn: async ({ logger, global }, { inputs, outputs }) => { + assert(inputs.path.value, 'Path input cannot be empty.'); + + const artifactSearchPaths = inputs.path.value + .toString() + .split('\n') + // It's easy to get an empty line with YAML + .filter((entry) => entry); + + const artifactsSearchResults = await Promise.allSettled( + artifactSearchPaths.map((patternOrPath) => + findArtifacts({ + rootDir: global.projectTargetDirectory, + patternOrPath, + // We're logging the error ourselves. + logger: null, + }) + ) + ); + + const artifactPaths = artifactsSearchResults.flatMap((result, index) => { + if (result.status === 'fulfilled') { + logger.info( + `Found ${result.value.length} paths matching "${artifactSearchPaths[index]}".` + ); + return result.value; + } + + if (result.status === 'rejected' && result.reason instanceof FindArtifactsError) { + logger.warn(`Did not find any paths matching "${artifactSearchPaths[index]}. Ignoring.`); + return []; + } + + throw result.reason; + }); + + const artifact = { + type: parseArtifactTypeInput({ + platform: ctx.job.platform, + inputValue: `${inputs.type.value ?? ''}`, + }), + paths: artifactPaths, + name: (inputs.name.value || inputs.key.value) as string, + }; + + try { + const { artifactId } = await ctx.runtimeApi.uploadArtifact({ + artifact, + logger, + }); + + if (artifactId) { + outputs.artifact_id.set(artifactId); + } + } catch (error) { + if (inputs.ignore_error.value) { + logger.error(`Failed to upload ${artifact.type}. Ignoring error.`, error); + // Ignoring error. + return; + } + + throw error; + } + }, + }); +} + +/** + * Initially, upload_artifact supported application-archive and build-artifact. + * Then, mistakenly, support for it was removed in favor of supporting ManagedArtifactType + * values. This makes sure we support all: + * - kebab-case managed artifact types (the original) + * - snake-caps-case managed artifact types (the mistake) + * - generic artifact types. + */ +function parseArtifactTypeInput({ + inputValue, + platform, +}: { + inputValue: string; + platform: Job['platform']; +}): GenericArtifactType | ManagedArtifactType { + if (!inputValue) { + // In build jobs the default artifact type is application-archive. + return platform ? ManagedArtifactType.APPLICATION_ARCHIVE : GenericArtifactType.OTHER; + } + + // Step's allowedValues ensures input is either + // a key of artifactTypeInputToManagedArtifactType + // or a value of an artifact type. + const translatedManagedArtifactType = artifactTypeInputToManagedArtifactType[inputValue]; + if (translatedManagedArtifactType) { + return translatedManagedArtifactType; + } + + return inputValue as GenericArtifactType | ManagedArtifactType; +} diff --git a/packages/build-tools/src/steps/functions/useNpmToken.ts b/packages/build-tools/src/steps/functions/useNpmToken.ts new file mode 100644 index 0000000000..84fd902e51 --- /dev/null +++ b/packages/build-tools/src/steps/functions/useNpmToken.ts @@ -0,0 +1,39 @@ +import path from 'path'; + +import fs from 'fs-extra'; +import { BuildFunction } from '@expo/steps'; + +import { findPackagerRootDir } from '../../utils/packageManager'; +import { NpmrcTemplate } from '../../templates/npmrc'; + +export function createSetUpNpmrcBuildFunction(): BuildFunction { + return new BuildFunction({ + namespace: 'eas', + id: 'use_npm_token', + name: 'Use NPM_TOKEN', + fn: async (stepCtx, { env }) => { + const { logger } = stepCtx; + if (env.NPM_TOKEN) { + logger.info('We detected that you set the NPM_TOKEN environment variable'); + const projectNpmrcPath = path.join(stepCtx.global.projectTargetDirectory, '.npmrc'); + if (await fs.pathExists(projectNpmrcPath)) { + logger.info('.npmrc already exists in your project directory, skipping generation'); + } else { + logger.info('Creating .npmrc in your project directory with the following contents:'); + logger.info(NpmrcTemplate); + await fs.writeFile(projectNpmrcPath, NpmrcTemplate); + } + } else { + const projectNpmrcPath = path.join(findPackagerRootDir(stepCtx.workingDirectory), '.npmrc'); + if (await fs.pathExists(projectNpmrcPath)) { + logger.info( + `.npmrc found at ${path.relative( + stepCtx.global.projectTargetDirectory, + projectNpmrcPath + )}` + ); + } + } + }, + }); +} diff --git a/packages/build-tools/src/steps/utils/__tests__/expoUpdates.test.ts b/packages/build-tools/src/steps/utils/__tests__/expoUpdates.test.ts new file mode 100644 index 0000000000..36b413f0e4 --- /dev/null +++ b/packages/build-tools/src/steps/utils/__tests__/expoUpdates.test.ts @@ -0,0 +1,88 @@ +import { Platform, BuildJob } from '@expo/eas-build-job'; +import { createLogger } from '@expo/logger'; +import { ExpoConfig } from '@expo/config'; + +import { configureEASUpdateAsync } from '../expoUpdates'; +import { androidSetChannelNativelyAsync } from '../android/expoUpdates'; +import { iosSetChannelNativelyAsync } from '../ios/expoUpdates'; + +jest.mock('../../../utils/getExpoUpdatesPackageVersionIfInstalledAsync'); +jest.mock('../ios/expoUpdates'); +jest.mock('../../../ios/expoUpdates'); +jest.mock('../android/expoUpdates'); +jest.mock('../../../android/expoUpdates'); +jest.mock('fs'); + +describe(configureEASUpdateAsync, () => { + beforeAll(() => { + jest.restoreAllMocks(); + }); + + it('aborts if updates.url (app config) is set but updates.channel (eas.json) is not', async () => { + await configureEASUpdateAsync({ + job: { platform: Platform.IOS } as unknown as BuildJob, + workingDirectory: '/app', + logger: createLogger({ + name: 'test', + }), + appConfig: { + updates: { + url: 'https://u.expo.dev/blahblah', + }, + } as unknown as ExpoConfig, + inputs: {}, + metadata: null, + }); + + expect(androidSetChannelNativelyAsync).not.toBeCalled(); + expect(iosSetChannelNativelyAsync).not.toBeCalled(); + }); + + it('configures for EAS if updates.channel (eas.json) and updates.url (app config) are set', async () => { + await configureEASUpdateAsync({ + job: { + updates: { + channel: 'main', + }, + platform: Platform.IOS, + } as unknown as BuildJob, + workingDirectory: '/app', + logger: createLogger({ + name: 'test', + }), + appConfig: { + updates: { + url: 'https://u.expo.dev/blahblah', + }, + } as unknown as ExpoConfig, + inputs: {}, + metadata: null, + }); + + expect(androidSetChannelNativelyAsync).not.toBeCalled(); + expect(iosSetChannelNativelyAsync).toBeCalledTimes(1); + }); + + it('configures for EAS if the updates.channel is set', async () => { + await configureEASUpdateAsync({ + job: { + updates: { channel: 'main' }, + platform: Platform.IOS, + } as unknown as BuildJob, + workingDirectory: '/app', + logger: createLogger({ + name: 'test', + }), + appConfig: { + updates: { + url: 'https://u.expo.dev/blahblah', + }, + } as unknown as ExpoConfig, + inputs: {}, + metadata: null, + }); + + expect(androidSetChannelNativelyAsync).not.toBeCalled(); + expect(iosSetChannelNativelyAsync).toBeCalledTimes(1); + }); +}); diff --git a/packages/build-tools/src/steps/utils/android/__tests__/__snapshots__/gradleConfig.test.ts.snap b/packages/build-tools/src/steps/utils/android/__tests__/__snapshots__/gradleConfig.test.ts.snap new file mode 100644 index 0000000000..a3738dc0de --- /dev/null +++ b/packages/build-tools/src/steps/utils/android/__tests__/__snapshots__/gradleConfig.test.ts.snap @@ -0,0 +1,289 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`gradleConfig injectConfigureVersionGradleConfig should create version gradle file with all variables substituted 1`] = ` +"// Build integration with EAS + +import java.nio.file.Paths + +def versionCodeVal = null +def versionNameVal = null + + versionCodeVal = "42" + + + versionNameVal = "2.3.4" + + +android { + defaultConfig { + if (versionCodeVal) { + versionCode = Integer.parseInt(versionCodeVal) + } + if (versionNameVal) { + versionName = versionNameVal + } + } + applicationVariants.all { variant -> + variant.outputs.each { output -> + if (versionCodeVal) { + output.versionCodeOverride = Integer.parseInt(versionCodeVal) + } + if (versionNameVal) { + output.versionNameOverride = versionNameVal + } + } + } +} +" +`; + +exports[`gradleConfig injectConfigureVersionGradleConfig should handle neither versionCode nor versionName provided 1`] = ` +"// Build integration with EAS + +import java.nio.file.Paths + +def versionCodeVal = null +def versionNameVal = null + + + +android { + defaultConfig { + if (versionCodeVal) { + versionCode = Integer.parseInt(versionCodeVal) + } + if (versionNameVal) { + versionName = versionNameVal + } + } + applicationVariants.all { variant -> + variant.outputs.each { output -> + if (versionCodeVal) { + output.versionCodeOverride = Integer.parseInt(versionCodeVal) + } + if (versionNameVal) { + output.versionNameOverride = versionNameVal + } + } + } +} +" +`; + +exports[`gradleConfig injectConfigureVersionGradleConfig should handle only versionCode provided 1`] = ` +"// Build integration with EAS + +import java.nio.file.Paths + +def versionCodeVal = null +def versionNameVal = null + + versionCodeVal = "123" + + + +android { + defaultConfig { + if (versionCodeVal) { + versionCode = Integer.parseInt(versionCodeVal) + } + if (versionNameVal) { + versionName = versionNameVal + } + } + applicationVariants.all { variant -> + variant.outputs.each { output -> + if (versionCodeVal) { + output.versionCodeOverride = Integer.parseInt(versionCodeVal) + } + if (versionNameVal) { + output.versionNameOverride = versionNameVal + } + } + } +} +" +`; + +exports[`gradleConfig injectConfigureVersionGradleConfig should handle only versionName provided 1`] = ` +"// Build integration with EAS + +import java.nio.file.Paths + +def versionCodeVal = null +def versionNameVal = null + + + versionNameVal = "1.2.3-beta" + + +android { + defaultConfig { + if (versionCodeVal) { + versionCode = Integer.parseInt(versionCodeVal) + } + if (versionNameVal) { + versionName = versionNameVal + } + } + applicationVariants.all { variant -> + variant.outputs.each { output -> + if (versionCodeVal) { + output.versionCodeOverride = Integer.parseInt(versionCodeVal) + } + if (versionNameVal) { + output.versionNameOverride = versionNameVal + } + } + } +} +" +`; + +exports[`gradleConfig injectConfigureVersionGradleConfig should handle version code as string with leading zeros 1`] = ` +"// Build integration with EAS + +import java.nio.file.Paths + +def versionCodeVal = null +def versionNameVal = null + + versionCodeVal = "0042" + + + versionNameVal = "1.0.0" + + +android { + defaultConfig { + if (versionCodeVal) { + versionCode = Integer.parseInt(versionCodeVal) + } + if (versionNameVal) { + versionName = versionNameVal + } + } + applicationVariants.all { variant -> + variant.outputs.each { output -> + if (versionCodeVal) { + output.versionCodeOverride = Integer.parseInt(versionCodeVal) + } + if (versionNameVal) { + output.versionNameOverride = versionNameVal + } + } + } +} +" +`; + +exports[`gradleConfig injectConfigureVersionGradleConfig should handle version name with special characters 1`] = ` +"// Build integration with EAS + +import java.nio.file.Paths + +def versionCodeVal = null +def versionNameVal = null + + versionCodeVal = "1" + + + versionNameVal = "1.0.0-rc.1+build.123" + + +android { + defaultConfig { + if (versionCodeVal) { + versionCode = Integer.parseInt(versionCodeVal) + } + if (versionNameVal) { + versionName = versionNameVal + } + } + applicationVariants.all { variant -> + variant.outputs.each { output -> + if (versionCodeVal) { + output.versionCodeOverride = Integer.parseInt(versionCodeVal) + } + if (versionNameVal) { + output.versionNameOverride = versionNameVal + } + } + } +} +" +`; + +exports[`gradleConfig injectConfigureVersionGradleConfig should produce valid Gradle syntax 1`] = ` +"// Build integration with EAS + +import java.nio.file.Paths + +def versionCodeVal = null +def versionNameVal = null + + versionCodeVal = "100" + + + versionNameVal = "3.0.0" + + +android { + defaultConfig { + if (versionCodeVal) { + versionCode = Integer.parseInt(versionCodeVal) + } + if (versionNameVal) { + versionName = versionNameVal + } + } + applicationVariants.all { variant -> + variant.outputs.each { output -> + if (versionCodeVal) { + output.versionCodeOverride = Integer.parseInt(versionCodeVal) + } + if (versionNameVal) { + output.versionNameOverride = versionNameVal + } + } + } +} +" +`; + +exports[`gradleConfig injectConfigureVersionGradleConfig should replace existing version gradle file 1`] = ` +"// Build integration with EAS + +import java.nio.file.Paths + +def versionCodeVal = null +def versionNameVal = null + + versionCodeVal = "999" + + + versionNameVal = "9.9.9" + + +android { + defaultConfig { + if (versionCodeVal) { + versionCode = Integer.parseInt(versionCodeVal) + } + if (versionNameVal) { + versionName = versionNameVal + } + } + applicationVariants.all { variant -> + variant.outputs.each { output -> + if (versionCodeVal) { + output.versionCodeOverride = Integer.parseInt(versionCodeVal) + } + if (versionNameVal) { + output.versionNameOverride = versionNameVal + } + } + } +} +" +`; diff --git a/packages/build-tools/src/steps/utils/android/__tests__/expoUpdates.test.ts b/packages/build-tools/src/steps/utils/android/__tests__/expoUpdates.test.ts new file mode 100644 index 0000000000..5584fbf88a --- /dev/null +++ b/packages/build-tools/src/steps/utils/android/__tests__/expoUpdates.test.ts @@ -0,0 +1,104 @@ +import path from 'path'; + +import { vol } from 'memfs'; +import { AndroidConfig } from '@expo/config-plugins'; + +import { + AndroidMetadataName, + androidSetChannelNativelyAsync, + androidGetNativelyDefinedChannelAsync, + androidSetRuntimeVersionNativelyAsync, +} from '../expoUpdates'; + +jest.mock('fs'); +const originalFs = jest.requireActual('fs'); + +const channel = 'easupdatechannel'; +const manifestPath = '/app/android/app/src/main/AndroidManifest.xml'; + +afterEach(() => { + vol.reset(); +}); + +describe(androidSetChannelNativelyAsync, () => { + it('sets the channel', async () => { + vol.fromJSON( + { + 'android/app/src/main/AndroidManifest.xml': originalFs.readFileSync( + path.join(__dirname, 'fixtures/NoMetadataAndroidManifest.xml'), + 'utf-8' + ), + }, + '/app' + ); + + await androidSetChannelNativelyAsync(channel, '/app'); + + const newAndroidManifest = await AndroidConfig.Manifest.readAndroidManifestAsync(manifestPath); + const newValue = AndroidConfig.Manifest.getMainApplicationMetaDataValue( + newAndroidManifest, + AndroidMetadataName.UPDATES_CONFIGURATION_REQUEST_HEADERS_KEY + ); + expect(newValue).toBeDefined(); + expect(JSON.parse(newValue!)).toEqual({ 'expo-channel-name': channel }); + }); +}); + +describe(androidGetNativelyDefinedChannelAsync, () => { + it('gets the channel', async () => { + vol.fromJSON( + { + 'android/app/src/main/AndroidManifest.xml': originalFs.readFileSync( + path.join(__dirname, 'fixtures/AndroidManifestWithChannel.xml'), + 'utf-8' + ), + }, + '/app' + ); + + await expect(androidGetNativelyDefinedChannelAsync('/app')).resolves.toBe('staging-123'); + }); +}); + +describe(androidSetRuntimeVersionNativelyAsync, () => { + it('sets the runtime version when nothing is set natively', async () => { + vol.fromJSON( + { + 'android/app/src/main/AndroidManifest.xml': originalFs.readFileSync( + path.join(__dirname, 'fixtures/NoMetadataAndroidManifest.xml'), + 'utf-8' + ), + }, + '/app' + ); + + await androidSetRuntimeVersionNativelyAsync('1.2.3', '/app'); + + const newAndroidManifest = await AndroidConfig.Manifest.readAndroidManifestAsync(manifestPath); + const newValue = AndroidConfig.Manifest.getMainApplicationMetaDataValue( + newAndroidManifest, + AndroidMetadataName.RUNTIME_VERSION + ); + expect(newValue).toBe('1.2.3'); + }); + it('updates the runtime version when value is already set natively', async () => { + vol.fromJSON( + { + 'android/app/src/main/AndroidManifest.xml': originalFs.readFileSync( + path.join(__dirname, 'fixtures/AndroidManifestWithRuntimeVersion.xml'), + 'utf-8' + ), + }, + '/app' + ); + + await androidSetRuntimeVersionNativelyAsync('1.2.3', '/app'); + + const newAndroidManifest = await AndroidConfig.Manifest.readAndroidManifestAsync(manifestPath); + const newValue = AndroidConfig.Manifest.getMainApplicationMetaDataValue( + newAndroidManifest, + AndroidMetadataName.RUNTIME_VERSION + ); + expect(newValue).toBe('1.2.3'); + }); +}); diff --git a/packages/build-tools/src/steps/utils/android/__tests__/fixtures/AndroidManifestWithChannel.xml b/packages/build-tools/src/steps/utils/android/__tests__/fixtures/AndroidManifestWithChannel.xml new file mode 100644 index 0000000000..70fe8e5707 --- /dev/null +++ b/packages/build-tools/src/steps/utils/android/__tests__/fixtures/AndroidManifestWithChannel.xml @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/packages/build-tools/src/steps/utils/android/__tests__/fixtures/AndroidManifestWithRuntimeVersion.xml b/packages/build-tools/src/steps/utils/android/__tests__/fixtures/AndroidManifestWithRuntimeVersion.xml new file mode 100644 index 0000000000..faf629bafa --- /dev/null +++ b/packages/build-tools/src/steps/utils/android/__tests__/fixtures/AndroidManifestWithRuntimeVersion.xml @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/packages/build-tools/src/steps/utils/android/__tests__/fixtures/NoMetadataAndroidManifest.xml b/packages/build-tools/src/steps/utils/android/__tests__/fixtures/NoMetadataAndroidManifest.xml new file mode 100644 index 0000000000..b680312954 --- /dev/null +++ b/packages/build-tools/src/steps/utils/android/__tests__/fixtures/NoMetadataAndroidManifest.xml @@ -0,0 +1,27 @@ + + + + + + + + + + + + + + + diff --git a/packages/build-tools/src/steps/utils/android/__tests__/gradleConfig.test.ts b/packages/build-tools/src/steps/utils/android/__tests__/gradleConfig.test.ts new file mode 100644 index 0000000000..bb15de9886 --- /dev/null +++ b/packages/build-tools/src/steps/utils/android/__tests__/gradleConfig.test.ts @@ -0,0 +1,280 @@ +import { vol } from 'memfs'; + +import { injectCredentialsGradleConfig, injectConfigureVersionGradleConfig } from '../gradleConfig'; +import { EasBuildInjectAndroidCredentialsGradle } from '../../../../templates/EasBuildInjectAndroidCredentialsGradle'; + +// Sample build.gradle content +const SAMPLE_BUILD_GRADLE = `apply plugin: "com.android.application" + +android { + compileSdkVersion 33 + + defaultConfig { + applicationId "com.example.app" + minSdkVersion 21 + targetSdkVersion 33 + versionCode 1 + versionName "1.0" + } +} +`; + +describe('gradleConfig', () => { + let mockLogger: any; + + beforeEach(() => { + vol.reset(); + mockLogger = { + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + }; + + // Set up Android project structure in the mock filesystem + vol.fromJSON({ + '/workingdir/android/app/build.gradle': SAMPLE_BUILD_GRADLE, + }); + }); + + afterEach(() => { + vol.reset(); + }); + + describe('injectCredentialsGradleConfig', () => { + it('should copy credentials gradle file to android/app directory', async () => { + await injectCredentialsGradleConfig(mockLogger, '/workingdir'); + + const credentialsGradlePath = + '/workingdir/android/app/eas-build-inject-android-credentials.gradle'; + const generatedContent = vol.readFileSync(credentialsGradlePath, 'utf-8') as string; + + // Verify the file was copied + expect(generatedContent).toBe(EasBuildInjectAndroidCredentialsGradle); + expect(generatedContent).toContain('// Build integration with EAS'); + expect(generatedContent).toContain('signingConfigs'); + expect(generatedContent).toContain('credentials.json'); + expect(generatedContent).toContain('android.keystore'); + }); + + it('should add apply statement to build.gradle', async () => { + await injectCredentialsGradleConfig(mockLogger, '/workingdir'); + + const buildGradlePath = '/workingdir/android/app/build.gradle'; + const buildGradleContent = vol.readFileSync(buildGradlePath, 'utf-8') as string; + + expect(buildGradleContent).toContain( + 'apply from: "./eas-build-inject-android-credentials.gradle"' + ); + }); + + it('should not duplicate apply statement if already present', async () => { + // Add the apply statement first + await injectCredentialsGradleConfig(mockLogger, '/workingdir'); + + const buildGradlePath = '/workingdir/android/app/build.gradle'; + const contentAfterFirst = vol.readFileSync(buildGradlePath, 'utf-8') as string; + const firstOccurrences = ( + contentAfterFirst.match( + /apply from: "\.\/eas-build-inject-android-credentials\.gradle"/g + ) ?? [] + ).length; + + // Call again + await injectCredentialsGradleConfig(mockLogger, '/workingdir'); + + const contentAfterSecond = vol.readFileSync(buildGradlePath, 'utf-8') as string; + const secondOccurrences = ( + contentAfterSecond.match( + /apply from: "\.\/eas-build-inject-android-credentials\.gradle"/g + ) ?? [] + ).length; + + expect(firstOccurrences).toBe(1); + expect(secondOccurrences).toBe(1); + }); + + it('should replace existing credentials gradle file', async () => { + // Create an old credentials file + const credentialsGradlePath = + '/workingdir/android/app/eas-build-inject-android-credentials.gradle'; + vol.writeFileSync(credentialsGradlePath, '// Old content'); + + await injectCredentialsGradleConfig(mockLogger, '/workingdir'); + + const generatedContent = vol.readFileSync(credentialsGradlePath, 'utf-8') as string; + expect(generatedContent).not.toContain('// Old content'); + expect(generatedContent).toBe(EasBuildInjectAndroidCredentialsGradle); + }); + + it('should log info messages', async () => { + await injectCredentialsGradleConfig(mockLogger, '/workingdir'); + + expect(mockLogger.info).toHaveBeenCalledWith('Injecting signing config into build.gradle'); + expect(mockLogger.info).toHaveBeenCalledWith('Signing config injected'); + }); + }); + + describe('injectConfigureVersionGradleConfig', () => { + it('should create version gradle file with all variables substituted', async () => { + await injectConfigureVersionGradleConfig(mockLogger, '/workingdir', { + versionCode: '42', + versionName: '2.3.4', + }); + + const versionGradlePath = '/workingdir/android/app/eas-build-configure-version.gradle'; + const generatedContent = vol.readFileSync(versionGradlePath, 'utf-8') as string; + expect(generatedContent).toMatchSnapshot(); + }); + + it('should handle only versionCode provided', async () => { + await injectConfigureVersionGradleConfig(mockLogger, '/workingdir', { + versionCode: '123', + }); + + const versionGradlePath = '/workingdir/android/app/eas-build-configure-version.gradle'; + const generatedContent = vol.readFileSync(versionGradlePath, 'utf-8') as string; + expect(generatedContent).toMatchSnapshot(); + }); + + it('should handle only versionName provided', async () => { + await injectConfigureVersionGradleConfig(mockLogger, '/workingdir', { + versionName: '1.2.3-beta', + }); + + const versionGradlePath = '/workingdir/android/app/eas-build-configure-version.gradle'; + const generatedContent = vol.readFileSync(versionGradlePath, 'utf-8') as string; + expect(generatedContent).toMatchSnapshot(); + }); + + it('should handle neither versionCode nor versionName provided', async () => { + await injectConfigureVersionGradleConfig(mockLogger, '/workingdir', {}); + + const versionGradlePath = '/workingdir/android/app/eas-build-configure-version.gradle'; + const generatedContent = vol.readFileSync(versionGradlePath, 'utf-8') as string; + expect(generatedContent).toMatchSnapshot(); + }); + + it('should add apply statement to build.gradle', async () => { + await injectConfigureVersionGradleConfig(mockLogger, '/workingdir', { + versionCode: '1', + versionName: '1.0.0', + }); + + const buildGradlePath = '/workingdir/android/app/build.gradle'; + const buildGradleContent = vol.readFileSync(buildGradlePath, 'utf-8') as string; + + expect(buildGradleContent).toContain('apply from: "./eas-build-configure-version.gradle"'); + }); + + it('should not duplicate apply statement if already present', async () => { + // Add the apply statement first + await injectConfigureVersionGradleConfig(mockLogger, '/workingdir', { + versionCode: '1', + }); + + const buildGradlePath = '/workingdir/android/app/build.gradle'; + const contentAfterFirst = vol.readFileSync(buildGradlePath, 'utf-8') as string; + const firstOccurrences = ( + contentAfterFirst.match(/apply from: "\.\/eas-build-configure-version\.gradle"/g) ?? [] + ).length; + + // Call again + await injectConfigureVersionGradleConfig(mockLogger, '/workingdir', { + versionName: '2.0.0', + }); + + const contentAfterSecond = vol.readFileSync(buildGradlePath, 'utf-8') as string; + const secondOccurrences = ( + contentAfterSecond.match(/apply from: "\.\/eas-build-configure-version\.gradle"/g) ?? [] + ).length; + + expect(firstOccurrences).toBe(1); + expect(secondOccurrences).toBe(1); + }); + + it('should replace existing version gradle file', async () => { + // Create an old version file + const versionGradlePath = '/workingdir/android/app/eas-build-configure-version.gradle'; + vol.writeFileSync(versionGradlePath, '// Old version content'); + + await injectConfigureVersionGradleConfig(mockLogger, '/workingdir', { + versionCode: '999', + versionName: '9.9.9', + }); + + const generatedContent = vol.readFileSync(versionGradlePath, 'utf-8') as string; + expect(generatedContent).toMatchSnapshot(); + }); + + it('should log info messages with version details', async () => { + await injectConfigureVersionGradleConfig(mockLogger, '/workingdir', { + versionCode: '42', + versionName: '2.3.4', + }); + + expect(mockLogger.info).toHaveBeenCalledWith('Injecting version config into build.gradle'); + expect(mockLogger.info).toHaveBeenCalledWith('Version code: 42'); + expect(mockLogger.info).toHaveBeenCalledWith('Version name: 2.3.4'); + expect(mockLogger.info).toHaveBeenCalledWith('Version config injected'); + }); + + it('should handle version code as string with leading zeros', async () => { + await injectConfigureVersionGradleConfig(mockLogger, '/workingdir', { + versionCode: '0042', + versionName: '1.0.0', + }); + + const versionGradlePath = '/workingdir/android/app/eas-build-configure-version.gradle'; + const generatedContent = vol.readFileSync(versionGradlePath, 'utf-8') as string; + expect(generatedContent).toMatchSnapshot(); + }); + + it('should handle version name with special characters', async () => { + await injectConfigureVersionGradleConfig(mockLogger, '/workingdir', { + versionCode: '1', + versionName: '1.0.0-rc.1+build.123', + }); + + const versionGradlePath = '/workingdir/android/app/eas-build-configure-version.gradle'; + const generatedContent = vol.readFileSync(versionGradlePath, 'utf-8') as string; + expect(generatedContent).toMatchSnapshot(); + }); + + it('should produce valid Gradle syntax', async () => { + await injectConfigureVersionGradleConfig(mockLogger, '/workingdir', { + versionCode: '100', + versionName: '3.0.0', + }); + + const versionGradlePath = '/workingdir/android/app/eas-build-configure-version.gradle'; + const generatedContent = vol.readFileSync(versionGradlePath, 'utf-8') as string; + expect(generatedContent).toMatchSnapshot(); + }); + }); + + describe('combined usage', () => { + it('should handle both credentials and version injection together', async () => { + await injectCredentialsGradleConfig(mockLogger, '/workingdir'); + await injectConfigureVersionGradleConfig(mockLogger, '/workingdir', { + versionCode: '50', + versionName: '5.0.0', + }); + + const buildGradlePath = '/workingdir/android/app/build.gradle'; + const buildGradleContent = vol.readFileSync(buildGradlePath, 'utf-8') as string; + + // Both apply statements should be present + expect(buildGradleContent).toContain( + 'apply from: "./eas-build-inject-android-credentials.gradle"' + ); + expect(buildGradleContent).toContain('apply from: "./eas-build-configure-version.gradle"'); + + // Both files should exist + const credentialsPath = '/workingdir/android/app/eas-build-inject-android-credentials.gradle'; + const versionPath = '/workingdir/android/app/eas-build-configure-version.gradle'; + + expect(vol.existsSync(credentialsPath)).toBe(true); + expect(vol.existsSync(versionPath)).toBe(true); + }); + }); +}); diff --git a/packages/build-tools/src/steps/utils/android/expoUpdates.ts b/packages/build-tools/src/steps/utils/android/expoUpdates.ts new file mode 100644 index 0000000000..27c246f264 --- /dev/null +++ b/packages/build-tools/src/steps/utils/android/expoUpdates.ts @@ -0,0 +1,81 @@ +import { AndroidConfig } from '@expo/config-plugins'; +import fs from 'fs-extra'; + +export enum AndroidMetadataName { + UPDATES_CONFIGURATION_REQUEST_HEADERS_KEY = 'expo.modules.updates.UPDATES_CONFIGURATION_REQUEST_HEADERS_KEY', + RELEASE_CHANNEL = 'expo.modules.updates.EXPO_RELEASE_CHANNEL', + RUNTIME_VERSION = 'expo.modules.updates.EXPO_RUNTIME_VERSION', +} + +export async function androidSetChannelNativelyAsync( + channel: string, + workingDirectory: string +): Promise { + const manifestPath = await AndroidConfig.Paths.getAndroidManifestAsync(workingDirectory); + + if (!(await fs.pathExists(manifestPath))) { + throw new Error(`Couldn't find Android manifest at ${manifestPath}`); + } + + const androidManifest = await AndroidConfig.Manifest.readAndroidManifestAsync(manifestPath); + const mainApp = AndroidConfig.Manifest.getMainApplicationOrThrow(androidManifest); + const stringifiedUpdatesRequestHeaders = AndroidConfig.Manifest.getMainApplicationMetaDataValue( + androidManifest, + AndroidMetadataName.UPDATES_CONFIGURATION_REQUEST_HEADERS_KEY + ); + AndroidConfig.Manifest.addMetaDataItemToMainApplication( + mainApp, + AndroidMetadataName.UPDATES_CONFIGURATION_REQUEST_HEADERS_KEY, + JSON.stringify({ + ...JSON.parse(stringifiedUpdatesRequestHeaders ?? '{}'), + 'expo-channel-name': channel, + }), + 'value' + ); + await AndroidConfig.Manifest.writeAndroidManifestAsync(manifestPath, androidManifest); +} + +export async function androidSetRuntimeVersionNativelyAsync( + runtimeVersion: string, + workingDirectory: string +): Promise { + const manifestPath = await AndroidConfig.Paths.getAndroidManifestAsync(workingDirectory); + + if (!(await fs.pathExists(manifestPath))) { + throw new Error(`Couldn't find Android manifest at ${manifestPath}`); + } + + const androidManifest = await AndroidConfig.Manifest.readAndroidManifestAsync(manifestPath); + const mainApp = AndroidConfig.Manifest.getMainApplicationOrThrow(androidManifest); + AndroidConfig.Manifest.addMetaDataItemToMainApplication( + mainApp, + AndroidMetadataName.RUNTIME_VERSION, + runtimeVersion, + 'value' + ); + await AndroidConfig.Manifest.writeAndroidManifestAsync(manifestPath, androidManifest); +} + +export async function androidGetNativelyDefinedChannelAsync( + workingDirectory: string +): Promise { + const manifestPath = await AndroidConfig.Paths.getAndroidManifestAsync(workingDirectory); + + if (!(await fs.pathExists(manifestPath))) { + return null; + } + + const androidManifest = await AndroidConfig.Manifest.readAndroidManifestAsync(manifestPath); + const stringifiedUpdatesRequestHeaders = AndroidConfig.Manifest.getMainApplicationMetaDataValue( + androidManifest, + AndroidMetadataName.UPDATES_CONFIGURATION_REQUEST_HEADERS_KEY + ); + try { + const updatesRequestHeaders = JSON.parse(stringifiedUpdatesRequestHeaders ?? '{}'); + return updatesRequestHeaders['expo-channel-name'] ?? null; + } catch (err: any) { + throw new Error( + `Failed to parse ${AndroidMetadataName.UPDATES_CONFIGURATION_REQUEST_HEADERS_KEY} from AndroidManifest.xml: ${err.message}` + ); + } +} diff --git a/packages/build-tools/src/steps/utils/android/gradle.ts b/packages/build-tools/src/steps/utils/android/gradle.ts new file mode 100644 index 0000000000..0f3a07a9e5 --- /dev/null +++ b/packages/build-tools/src/steps/utils/android/gradle.ts @@ -0,0 +1,122 @@ +import assert from 'assert'; +import path from 'path'; + +import fs from 'fs-extra'; +import { bunyan } from '@expo/logger'; +import { BuildStepEnv } from '@expo/steps'; +import spawn, { SpawnPromise, SpawnResult } from '@expo/turtle-spawn'; +import { Android } from '@expo/eas-build-job'; + +export async function runGradleCommand({ + logger, + gradleCommand, + androidDir, + env, + extraEnv, +}: { + logger: bunyan; + gradleCommand: string; + androidDir: string; + env: BuildStepEnv; + extraEnv?: BuildStepEnv; +}): Promise { + const verboseFlag = env['EAS_VERBOSE'] === '1' ? '--info' : ''; + + logger.info(`Running 'gradlew ${gradleCommand} ${verboseFlag}' in ${androidDir}`); + await fs.chmod(path.join(androidDir, 'gradlew'), 0o755); + const spawnPromise = spawn('bash', ['-c', `./gradlew ${gradleCommand} ${verboseFlag}`], { + cwd: androidDir, + logger, + lineTransformer: (line?: string) => { + if (!line || /^\.+$/.exec(line)) { + return null; + } else { + return line; + } + }, + env: { ...env, ...extraEnv }, + }); + if (env.EAS_BUILD_RUNNER === 'eas-build' && process.platform === 'linux') { + adjustOOMScore(spawnPromise, logger); + } + + await spawnPromise; +} + +/** + * OOM Killer sometimes kills worker server while build is exceeding memory limits. + * `oom_score_adj` is a value between -1000 and 1000 and it defaults to 0. + * It defines which process is more likely to get killed (higher value more likely). + * + * This function sets oom_score_adj for Gradle process and all its child processes. + */ +function adjustOOMScore(spawnPromise: SpawnPromise, logger: bunyan): void { + setTimeout( + async () => { + try { + assert(spawnPromise.child.pid); + const pids = await getParentAndDescendantProcessPidsAsync(spawnPromise.child.pid); + await Promise.all( + pids.map(async (pid: number) => { + // Value 800 is just a guess here. It's probably higher than most other + // process. I didn't want to set it any higher, because I'm not sure if OOM Killer + // can start killing processes when there is still enough memory left. + const oomScoreOverride = 800; + await fs.writeFile(`/proc/${pid}/oom_score_adj`, `${oomScoreOverride}\n`); + }) + ); + } catch (err: any) { + logger.debug({ err, stderr: err?.stderr }, 'Failed to override oom_score_adj'); + } + }, + // Wait 20 seconds to make sure all child processes are started + 20000 + ); +} + +async function getChildrenPidsAsync(parentPids: number[]): Promise { + try { + const result = await spawn('pgrep', ['-P', parentPids.join(',')], { + stdio: 'pipe', + }); + return result.stdout + .toString() + .split('\n') + .map((i) => Number(i.trim())) + .filter((i) => i); + } catch { + return []; + } +} + +async function getParentAndDescendantProcessPidsAsync(ppid: number): Promise { + const children = new Set([ppid]); + let shouldCheckAgain = true; + while (shouldCheckAgain) { + const pids = await getChildrenPidsAsync([...children]); + shouldCheckAgain = false; + for (const pid of pids) { + if (!children.has(pid)) { + shouldCheckAgain = true; + children.add(pid); + } + } + } + return [...children]; +} + +export function resolveGradleCommand(job: Android.Job, command?: string): string { + if (command) { + return command; + } else if (job.gradleCommand) { + return job.gradleCommand; + } else if (job.developmentClient) { + return ':app:assembleDebug'; + } else if (!job.buildType) { + return ':app:bundleRelease'; + } else if (job.buildType === Android.BuildType.APK) { + return ':app:assembleRelease'; + } else { + return ':app:bundleRelease'; + } +} diff --git a/packages/build-tools/src/steps/utils/android/gradleConfig.ts b/packages/build-tools/src/steps/utils/android/gradleConfig.ts new file mode 100644 index 0000000000..d29b44027e --- /dev/null +++ b/packages/build-tools/src/steps/utils/android/gradleConfig.ts @@ -0,0 +1,120 @@ +import path from 'path'; + +import { AndroidConfig } from '@expo/config-plugins'; +import { bunyan } from '@expo/logger'; +import fs from 'fs-extra'; +import { templateString } from '@expo/template-file'; + +import { EasBuildInjectAndroidCredentialsGradle } from '../../../templates/EasBuildInjectAndroidCredentialsGradle'; +import { EasBuildConfigureVersionGradleTemplate } from '../../../templates/EasBuildConfigureVersionGradle'; + +const APPLY_EAS_BUILD_INJECT_CREDENTIALS_GRADLE_LINE = + 'apply from: "./eas-build-inject-android-credentials.gradle"'; +const APPLY_EAS_BUILD_CONFIGURE_VERSION_GRADLE_LINE = + 'apply from: "./eas-build-configure-version.gradle"'; + +export async function injectCredentialsGradleConfig( + logger: bunyan, + workingDir: string +): Promise { + logger.info('Injecting signing config into build.gradle'); + await deleteEasBuildInjectCredentialsGradle(workingDir); + await createEasBuildInjectCredentialsGradle(workingDir); + await addApplyInjectCredentialsConfigToBuildGradle(workingDir); + logger.info('Signing config injected'); +} + +export async function injectConfigureVersionGradleConfig( + logger: bunyan, + workingDir: string, + { versionCode, versionName }: { versionCode?: string; versionName?: string } +): Promise { + logger.info('Injecting version config into build.gradle'); + if (versionCode) { + logger.info(`Version code: ${versionCode}`); + } + if (versionName) { + logger.info(`Version name: ${versionName}`); + } + await deleteEasBuildConfigureVersionGradle(workingDir); + await createEasBuildConfigureVersionGradle(workingDir, { versionCode, versionName }); + await addApplyConfigureVersionConfigToBuildGradle(workingDir); + logger.info('Version config injected'); +} + +async function deleteEasBuildInjectCredentialsGradle(workingDir: string): Promise { + const targetPath = getEasBuildInjectCredentialsGradlePath(workingDir); + await fs.remove(targetPath); +} + +async function deleteEasBuildConfigureVersionGradle(workingDir: string): Promise { + const targetPath = getEasBuildConfigureVersionGradlePath(workingDir); + await fs.remove(targetPath); +} + +function getEasBuildInjectCredentialsGradlePath(workingDir: string): string { + return path.join(workingDir, 'android/app/eas-build-inject-android-credentials.gradle'); +} + +function getEasBuildConfigureVersionGradlePath(workingDir: string): string { + return path.join(workingDir, 'android/app/eas-build-configure-version.gradle'); +} + +async function createEasBuildInjectCredentialsGradle(workingDir: string): Promise { + const targetPath = getEasBuildInjectCredentialsGradlePath(workingDir); + await fs.writeFile(targetPath, EasBuildInjectAndroidCredentialsGradle); +} + +async function createEasBuildConfigureVersionGradle( + workingDir: string, + { versionCode, versionName }: { versionCode?: string; versionName?: string } +): Promise { + const targetPath = getEasBuildConfigureVersionGradlePath(workingDir); + const output = templateString({ + input: EasBuildConfigureVersionGradleTemplate, + vars: { + VERSION_CODE: versionCode, + VERSION_NAME: versionName, + }, + mustache: false, + }); + await fs.writeFile(targetPath, output); +} + +async function addApplyInjectCredentialsConfigToBuildGradle(projectRoot: string): Promise { + const buildGradlePath = AndroidConfig.Paths.getAppBuildGradleFilePath(projectRoot); + const buildGradleContents = await fs.readFile(path.join(buildGradlePath), 'utf8'); + + if (hasLine(buildGradleContents, APPLY_EAS_BUILD_INJECT_CREDENTIALS_GRADLE_LINE)) { + return; + } + + await fs.writeFile( + buildGradlePath, + `${buildGradleContents.trim()}\n${APPLY_EAS_BUILD_INJECT_CREDENTIALS_GRADLE_LINE}\n` + ); +} + +async function addApplyConfigureVersionConfigToBuildGradle(projectRoot: string): Promise { + const buildGradlePath = AndroidConfig.Paths.getAppBuildGradleFilePath(projectRoot); + const buildGradleContents = await fs.readFile(path.join(buildGradlePath), 'utf8'); + + if (hasLine(buildGradleContents, APPLY_EAS_BUILD_CONFIGURE_VERSION_GRADLE_LINE)) { + return; + } + + await fs.writeFile( + buildGradlePath, + `${buildGradleContents.trim()}\n${APPLY_EAS_BUILD_CONFIGURE_VERSION_GRADLE_LINE}\n` + ); +} + +function hasLine(haystack: string, needle: string): boolean { + return ( + haystack + .replace(/\r\n/g, '\n') + .split('\n') + // Check for both single and double quotes + .some((line) => line === needle || line === needle.replace(/"/g, "'")) + ); +} diff --git a/packages/build-tools/src/steps/utils/cache.ts b/packages/build-tools/src/steps/utils/cache.ts new file mode 100644 index 0000000000..7d9e349915 --- /dev/null +++ b/packages/build-tools/src/steps/utils/cache.ts @@ -0,0 +1,7 @@ +import { createHash } from 'crypto'; + +export function getCacheVersion(paths: string[]): string { + return createHash('sha256') + .update(`${process.platform}@${process.arch}#${paths.join('|')}`) + .digest('hex'); +} diff --git a/packages/build-tools/src/steps/utils/expoUpdates.ts b/packages/build-tools/src/steps/utils/expoUpdates.ts new file mode 100644 index 0000000000..65dd76efde --- /dev/null +++ b/packages/build-tools/src/steps/utils/expoUpdates.ts @@ -0,0 +1,161 @@ +import { BuildJob, Job, Metadata, Platform } from '@expo/eas-build-job'; +import { bunyan } from '@expo/logger'; +import { ExpoConfig } from '@expo/config'; + +import { + iosGetNativelyDefinedChannelAsync, + iosSetChannelNativelyAsync, + iosSetRuntimeVersionNativelyAsync, +} from './ios/expoUpdates'; +import { + androidGetNativelyDefinedChannelAsync, + androidSetChannelNativelyAsync, + androidSetRuntimeVersionNativelyAsync, +} from './android/expoUpdates'; + +export async function configureEASUpdateAsync({ + job, + workingDirectory, + logger, + inputs, + appConfig, + metadata, +}: { + job: BuildJob; + workingDirectory: string; + logger: bunyan; + inputs: { + runtimeVersion?: string; + channel?: string; + resolvedRuntimeVersion?: string; + }; + appConfig: ExpoConfig; + metadata: Metadata | null; +}): Promise { + const runtimeVersion = + inputs.runtimeVersion ?? job.version?.runtimeVersion ?? inputs.resolvedRuntimeVersion; + + if (metadata?.runtimeVersion && metadata.runtimeVersion !== runtimeVersion) { + logger.warn( + `Runtime version from the app config evaluated on your local machine (${metadata.runtimeVersion}) does not match the one resolved here (${runtimeVersion}).` + ); + logger.warn( + "If you're using conditional app configs, e.g. depending on an environment variable, make sure to set the variable in eas.json or configure it with EAS environment variables." + ); + } + + const jobOrInputChannel = inputs.channel ?? job.updates?.channel; + + if (isEASUpdateConfigured(appConfig, logger)) { + if (jobOrInputChannel) { + await configureEASUpdate(job, logger, jobOrInputChannel, workingDirectory); + } else { + const channel = await getChannelAsync(job, workingDirectory); + const isDevelopmentClient = job.developmentClient ?? false; + + if (channel) { + const configFile = job.platform === Platform.ANDROID ? 'AndroidManifest.xml' : 'Expo.plist'; + logger.info(`The channel name for EAS Update in ${configFile} is set to "${channel}"`); + } else if (isDevelopmentClient) { + // NO-OP: Development clients don't need to have a channel set + } else { + const easUpdateUrl = appConfig.updates?.url ?? null; + const jobProfile = job.buildProfile ?? null; + logger.warn( + `This build has an invalid EAS Update configuration: update.url is set to "${easUpdateUrl}" in app config, but a channel is not specified${ + jobProfile ? '' : ` for the current build profile "${jobProfile}" in eas.json` + }.` + ); + logger.warn(`- No channel will be set and EAS Update will be disabled for the build.`); + logger.warn( + `- Run \`eas update:configure\` to set your channel in eas.json. For more details, see https://docs.expo.dev/eas-update/getting-started/#configure-your-project` + ); + } + } + } else { + logger.info(`Expo Updates is not configured, skipping configuring Expo Updates.`); + } + + if (runtimeVersion) { + logger.info('Updating runtimeVersion in Expo.plist'); + await setRuntimeVersionNativelyAsync(job, runtimeVersion, workingDirectory); + } +} + +export function isEASUpdateConfigured(appConfig: ExpoConfig, logger: bunyan): boolean { + const rawUrl = appConfig.updates?.url; + if (!rawUrl) { + return false; + } + try { + const url = new URL(rawUrl); + return ['u.expo.dev', 'staging-u.expo.dev'].includes(url.hostname); + } catch (err) { + logger.error({ err }, `Cannot parse expo.updates.url = ${rawUrl} as URL`); + logger.error(`Assuming EAS Update is not configured`); + return false; + } +} + +async function configureEASUpdate( + job: Job, + logger: bunyan, + channel: string, + workingDirectory: string +): Promise { + const newUpdateRequestHeaders: Record = { + 'expo-channel-name': channel, + }; + + const configFile = job.platform === Platform.ANDROID ? 'AndroidManifest.xml' : 'Expo.plist'; + logger.info( + `Setting the update request headers in '${configFile}' to '${JSON.stringify( + newUpdateRequestHeaders + )}'` + ); + + switch (job.platform) { + case Platform.ANDROID: { + await androidSetChannelNativelyAsync(channel, workingDirectory); + return; + } + case Platform.IOS: { + await iosSetChannelNativelyAsync(channel, workingDirectory); + return; + } + default: + throw new Error(`Platform is not supported.`); + } +} + +async function getChannelAsync(job: Job, workingDirectory: string): Promise { + switch (job.platform) { + case Platform.ANDROID: { + return await androidGetNativelyDefinedChannelAsync(workingDirectory); + } + case Platform.IOS: { + return await iosGetNativelyDefinedChannelAsync(workingDirectory); + } + default: + throw new Error(`Platform is not supported.`); + } +} + +async function setRuntimeVersionNativelyAsync( + job: Job, + runtimeVersion: string, + workingDirectory: string +): Promise { + switch (job.platform) { + case Platform.ANDROID: { + await androidSetRuntimeVersionNativelyAsync(runtimeVersion, workingDirectory); + return; + } + case Platform.IOS: { + await iosSetRuntimeVersionNativelyAsync(runtimeVersion, workingDirectory); + return; + } + default: + throw new Error(`Platform is not supported.`); + } +} diff --git a/packages/build-tools/src/steps/utils/ios/__tests__/__snapshots__/configure.test.ts.snap b/packages/build-tools/src/steps/utils/ios/__tests__/__snapshots__/configure.test.ts.snap new file mode 100644 index 0000000000..33d574c8cb --- /dev/null +++ b/packages/build-tools/src/steps/utils/ios/__tests__/__snapshots__/configure.test.ts.snap @@ -0,0 +1,2566 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`configureCredentialsAsync configures credentials for a simple project 1`] = ` +"// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 46; + objects = { + +/* Begin PBXBuildFile section */ + 13B07FBC1A68108700A75B9A /* AppDelegate.m in Sources */ = {isa = PBXBuildFile; fileRef = 13B07FB01A68108700A75B9A /* AppDelegate.m */; }; + 13B07FBF1A68108700A75B9A /* Images.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 13B07FB51A68108700A75B9A /* Images.xcassets */; }; + 13B07FC11A68108700A75B9A /* main.m in Sources */ = {isa = PBXBuildFile; fileRef = 13B07FB71A68108700A75B9A /* main.m */; }; + 3E461D99554A48A4959DE609 /* SplashScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = AA286B85B6C04FC6940260E9 /* SplashScreen.storyboard */; }; + 96905EF65AED1B983A6B3ABC /* libPods-testapp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 58EEBF8E8E6FB1BC6CAF49B5 /* libPods-testapp.a */; }; + B18059E884C0ABDD17F3DC3D /* ExpoModulesProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = FAC715A2D49A985799AEE119 /* ExpoModulesProvider.swift */; }; + BB2F792D24A3F905000567C9 /* Expo.plist in Resources */ = {isa = PBXBuildFile; fileRef = BB2F792C24A3F905000567C9 /* Expo.plist */; }; + E112987CA504453081AC7CD8 /* noop-file.swift in Sources */ = {isa = PBXBuildFile; fileRef = C911AA20BDD841449D1CE041 /* noop-file.swift */; }; +/* End PBXBuildFile section */ + +/* Begin PBXFileReference section */ + 008F07F21AC5B25A0029DE68 /* main.jsbundle */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text; path = main.jsbundle; sourceTree = ""; }; + 13B07F961A680F5B00A75B9A /* testapp.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = testapp.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 13B07FAF1A68108700A75B9A /* AppDelegate.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; name = AppDelegate.h; path = testapp/AppDelegate.h; sourceTree = ""; }; + 13B07FB01A68108700A75B9A /* AppDelegate.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; name = AppDelegate.m; path = testapp/AppDelegate.m; sourceTree = ""; }; + 13B07FB51A68108700A75B9A /* Images.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; name = Images.xcassets; path = testapp/Images.xcassets; sourceTree = ""; }; + 13B07FB61A68108700A75B9A /* Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = Info.plist; path = testapp/Info.plist; sourceTree = ""; }; + 13B07FB71A68108700A75B9A /* main.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; name = main.m; path = testapp/main.m; sourceTree = ""; }; + 58EEBF8E8E6FB1BC6CAF49B5 /* libPods-testapp.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = "libPods-testapp.a"; sourceTree = BUILT_PRODUCTS_DIR; }; + 6C2E3173556A471DD304B334 /* Pods-testapp.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-testapp.debug.xcconfig"; path = "Target Support Files/Pods-testapp/Pods-testapp.debug.xcconfig"; sourceTree = ""; }; + 7A4D352CD337FB3A3BF06240 /* Pods-testapp.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-testapp.release.xcconfig"; path = "Target Support Files/Pods-testapp/Pods-testapp.release.xcconfig"; sourceTree = ""; }; + AA286B85B6C04FC6940260E9 /* SplashScreen.storyboard */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.storyboard; name = SplashScreen.storyboard; path = testapp/SplashScreen.storyboard; sourceTree = ""; }; + BB2F792C24A3F905000567C9 /* Expo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = Expo.plist; sourceTree = ""; }; + ED297162215061F000B7C4FE /* JavaScriptCore.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = JavaScriptCore.framework; path = System/Library/Frameworks/JavaScriptCore.framework; sourceTree = SDKROOT; }; + FAC715A2D49A985799AEE119 /* ExpoModulesProvider.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = ExpoModulesProvider.swift; path = "Pods/Target Support Files/Pods-testapp/ExpoModulesProvider.swift"; sourceTree = ""; }; + C911AA20BDD841449D1CE041 /* noop-file.swift */ = {isa = PBXFileReference; name = "noop-file.swift"; path = "testapp/noop-file.swift"; sourceTree = ""; fileEncoding = 4; lastKnownFileType = sourcecode.swift; explicitFileType = undefined; includeInIndex = 0; }; + 0962172184234A4793928022 /* testapp-Bridging-Header.h */ = {isa = PBXFileReference; name = "testapp-Bridging-Header.h"; path = "testapp/testapp-Bridging-Header.h"; sourceTree = ""; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; explicitFileType = undefined; includeInIndex = 0; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + 13B07F8C1A680F5B00A75B9A /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 96905EF65AED1B983A6B3ABC /* libPods-testapp.a in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 13B07FAE1A68108700A75B9A /* testapp */ = { + isa = PBXGroup; + children = ( + BB2F792B24A3F905000567C9 /* Supporting */, + 008F07F21AC5B25A0029DE68 /* main.jsbundle */, + 13B07FAF1A68108700A75B9A /* AppDelegate.h */, + 13B07FB01A68108700A75B9A /* AppDelegate.m */, + 13B07FB51A68108700A75B9A /* Images.xcassets */, + 13B07FB61A68108700A75B9A /* Info.plist */, + 13B07FB71A68108700A75B9A /* main.m */, + AA286B85B6C04FC6940260E9 /* SplashScreen.storyboard */, + C911AA20BDD841449D1CE041 /* noop-file.swift */, + 0962172184234A4793928022 /* testapp-Bridging-Header.h */, + ); + name = testapp; + sourceTree = ""; + }; + 2D16E6871FA4F8E400B85C8A /* Frameworks */ = { + isa = PBXGroup; + children = ( + ED297162215061F000B7C4FE /* JavaScriptCore.framework */, + 58EEBF8E8E6FB1BC6CAF49B5 /* libPods-testapp.a */, + ); + name = Frameworks; + sourceTree = ""; + }; + 832341AE1AAA6A7D00B99B32 /* Libraries */ = { + isa = PBXGroup; + children = ( + ); + name = Libraries; + sourceTree = ""; + }; + 83CBB9F61A601CBA00E9B192 = { + isa = PBXGroup; + children = ( + 13B07FAE1A68108700A75B9A /* testapp */, + 832341AE1AAA6A7D00B99B32 /* Libraries */, + 83CBBA001A601CBA00E9B192 /* Products */, + 2D16E6871FA4F8E400B85C8A /* Frameworks */, + D65327D7A22EEC0BE12398D9 /* Pods */, + D7E4C46ADA2E9064B798F356 /* ExpoModulesProviders */, + ); + indentWidth = 2; + sourceTree = ""; + tabWidth = 2; + usesTabs = 0; + }; + 83CBBA001A601CBA00E9B192 /* Products */ = { + isa = PBXGroup; + children = ( + 13B07F961A680F5B00A75B9A /* testapp.app */, + ); + name = Products; + sourceTree = ""; + }; + 92DBD88DE9BF7D494EA9DA96 /* testapp */ = { + isa = PBXGroup; + children = ( + FAC715A2D49A985799AEE119 /* ExpoModulesProvider.swift */, + ); + name = testapp; + sourceTree = ""; + }; + BB2F792B24A3F905000567C9 /* Supporting */ = { + isa = PBXGroup; + children = ( + BB2F792C24A3F905000567C9 /* Expo.plist */, + ); + name = Supporting; + path = testapp/Supporting; + sourceTree = ""; + }; + D65327D7A22EEC0BE12398D9 /* Pods */ = { + isa = PBXGroup; + children = ( + 6C2E3173556A471DD304B334 /* Pods-testapp.debug.xcconfig */, + 7A4D352CD337FB3A3BF06240 /* Pods-testapp.release.xcconfig */, + ); + path = Pods; + sourceTree = ""; + }; + D7E4C46ADA2E9064B798F356 /* ExpoModulesProviders */ = { + isa = PBXGroup; + children = ( + 92DBD88DE9BF7D494EA9DA96 /* testapp */, + ); + name = ExpoModulesProviders; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 13B07F861A680F5B00A75B9A /* testapp */ = { + isa = PBXNativeTarget; + buildConfigurationList = 13B07F931A680F5B00A75B9A /* Build configuration list for PBXNativeTarget "testapp" */; + buildPhases = ( + 08A4A3CD28434E44B6B9DE2E /* [CP] Check Pods Manifest.lock */, + FD10A7F022414F080027D42C /* Start Packager */, + 13B07F871A680F5B00A75B9A /* Sources */, + 13B07F8C1A680F5B00A75B9A /* Frameworks */, + 13B07F8E1A680F5B00A75B9A /* Resources */, + 00DD1BFF1BD5951E006B06BC /* Bundle React Native code and images */, + 800E24972A6A228C8D4807E9 /* [CP] Copy Pods Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = testapp; + productName = testapp; + productReference = 13B07F961A680F5B00A75B9A /* testapp.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 83CBB9F71A601CBA00E9B192 /* Project object */ = { + isa = PBXProject; + attributes = { + LastUpgradeCheck = 1130; + TargetAttributes = { + 13B07F861A680F5B00A75B9A = { + LastSwiftMigration = 1250; + DevelopmentTeam = "ABCDEFGH"; + ProvisioningStyle = Manual; + }; + }; + }; + buildConfigurationList = 83CBB9FA1A601CBA00E9B192 /* Build configuration list for PBXProject "testapp" */; + compatibilityVersion = "Xcode 3.2"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = 83CBB9F61A601CBA00E9B192; + productRefGroup = 83CBBA001A601CBA00E9B192 /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 13B07F861A680F5B00A75B9A /* testapp */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 13B07F8E1A680F5B00A75B9A /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + BB2F792D24A3F905000567C9 /* Expo.plist in Resources */, + 13B07FBF1A68108700A75B9A /* Images.xcassets in Resources */, + 3E461D99554A48A4959DE609 /* SplashScreen.storyboard in Resources */, + C9A08D1C39C94E5D824DFA3F /* testapp-Bridging-Header.h in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXShellScriptBuildPhase section */ + 00DD1BFF1BD5951E006B06BC /* Bundle React Native code and images */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + ); + name = "Bundle React Native code and images"; + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "export NODE_BINARY=node\\n\\n# The project root by default is one level up from the ios directory\\nexport PROJECT_ROOT=\\"$PROJECT_DIR\\"/..\\n\\n\`node --print \\"require('path').dirname(require.resolve('react-native/package.json')) + '/scripts/react-native-xcode.sh'\\"\`\\n"; + }; + 08A4A3CD28434E44B6B9DE2E /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "\${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "\${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-testapp-checkManifestLockResult.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "diff \\"\${PODS_PODFILE_DIR_PATH}/Podfile.lock\\" \\"\${PODS_ROOT}/Manifest.lock\\" > /dev/null\\nif [ $? != 0 ] ; then\\n # print error to STDERR\\n echo \\"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\\" >&2\\n exit 1\\nfi\\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\\necho \\"SUCCESS\\" > \\"\${SCRIPT_OUTPUT_FILE_0}\\"\\n"; + showEnvVarsInLog = 0; + }; + 800E24972A6A228C8D4807E9 /* [CP] Copy Pods Resources */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + "\${PODS_ROOT}/Target Support Files/Pods-testapp/Pods-testapp-resources.sh", + "\${PODS_CONFIGURATION_BUILD_DIR}/EXConstants/EXConstants.bundle", + "\${PODS_CONFIGURATION_BUILD_DIR}/EXUpdates/EXUpdates.bundle", + "\${PODS_CONFIGURATION_BUILD_DIR}/React-Core/AccessibilityResources.bundle", + ); + name = "[CP] Copy Pods Resources"; + outputPaths = ( + "\${TARGET_BUILD_DIR}/\${UNLOCALIZED_RESOURCES_FOLDER_PATH}/EXConstants.bundle", + "\${TARGET_BUILD_DIR}/\${UNLOCALIZED_RESOURCES_FOLDER_PATH}/EXUpdates.bundle", + "\${TARGET_BUILD_DIR}/\${UNLOCALIZED_RESOURCES_FOLDER_PATH}/AccessibilityResources.bundle", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\\"\${PODS_ROOT}/Target Support Files/Pods-testapp/Pods-testapp-resources.sh\\"\\n"; + showEnvVarsInLog = 0; + }; + FD10A7F022414F080027D42C /* Start Packager */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + ); + name = "Start Packager"; + outputFileListPaths = ( + ); + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "export RCT_METRO_PORT=\\"\${RCT_METRO_PORT:=8081}\\"\\necho \\"export RCT_METRO_PORT=\${RCT_METRO_PORT}\\" > \`node --print \\"require('path').dirname(require.resolve('react-native/package.json')) + '/scripts/.packager.env'\\"\`\\nif [ -z \\"\${RCT_NO_LAUNCH_PACKAGER+xxx}\\" ] ; then\\n if nc -w 5 -z localhost \${RCT_METRO_PORT} ; then\\n if ! curl -s \\"http://localhost:\${RCT_METRO_PORT}/status\\" | grep -q \\"packager-status:running\\" ; then\\n echo \\"Port \${RCT_METRO_PORT} already in use, packager is either not running or not running correctly\\"\\n exit 2\\n fi\\n else\\n open \`node --print \\"require('path').dirname(require.resolve('react-native/package.json')) + '/scripts/launchPackager.command'\\"\` || echo \\"Can't start packager automatically\\"\\n fi\\nfi\\n"; + showEnvVarsInLog = 0; + }; +/* End PBXShellScriptBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 13B07F871A680F5B00A75B9A /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 13B07FBC1A68108700A75B9A /* AppDelegate.m in Sources */, + 13B07FC11A68108700A75B9A /* main.m in Sources */, + B18059E884C0ABDD17F3DC3D /* ExpoModulesProvider.swift in Sources */, + E112987CA504453081AC7CD8 /* noop-file.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin XCBuildConfiguration section */ + 13B07F941A680F5B00A75B9A /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 6C2E3173556A471DD304B334 /* Pods-testapp.debug.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CURRENT_PROJECT_VERSION = 1; + ENABLE_BITCODE = NO; + GCC_PREPROCESSOR_DEFINITIONS = ( + "$(inherited)", + "FB_SONARKIT_ENABLED=1", + ); + INFOPLIST_FILE = testapp/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 12.0; + LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; + OTHER_LDFLAGS = ( + "$(inherited)", + "-ObjC", + "-lc++", + ); + PRODUCT_BUNDLE_IDENTIFIER = "org.reactjs.native.example.testapp.turtlev2"; + PRODUCT_NAME = turtlev2; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + TARGETED_DEVICE_FAMILY = "1,2"; + SWIFT_OBJC_BRIDGING_HEADER = testapp/testapp-Bridging-Header.h; + CODE_SIGN_ENTITLEMENTS = testapp/turtlev2.entitlements; + }; + name = Debug; + }; + 13B07F951A680F5B00A75B9A /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7A4D352CD337FB3A3BF06240 /* Pods-testapp.release.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CURRENT_PROJECT_VERSION = 1; + INFOPLIST_FILE = testapp/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 12.0; + LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; + OTHER_LDFLAGS = ( + "$(inherited)", + "-ObjC", + "-lc++", + ); + PRODUCT_BUNDLE_IDENTIFIER = "org.reactjs.native.example.testapp.turtlev2"; + PRODUCT_NAME = turtlev2; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + TARGETED_DEVICE_FAMILY = "1,2"; + SWIFT_OBJC_BRIDGING_HEADER = testapp/testapp-Bridging-Header.h; + CODE_SIGN_ENTITLEMENTS = testapp/turtlev2.entitlements; + PROVISIONING_PROFILE_SPECIFIER = "profile name"; + DEVELOPMENT_TEAM = "ABCDEFGH"; + CODE_SIGN_IDENTITY = "iPhone Distribution"; + CODE_SIGN_STYLE = Manual; + }; + name = Release; + }; + 83CBBA201A601CBA00E9B192 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_SYMBOLS_PRIVATE_EXTERN = NO; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 12.0; + LD_RUNPATH_SEARCH_PATHS = "/usr/lib/swift $(inherited)"; + LIBRARY_SEARCH_PATHS = "\\"$(inherited)\\""; + MTL_ENABLE_DEBUG_INFO = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = iphoneos; + CODE_SIGN_ENTITLEMENTS = testapp/turtlev2.entitlements; + }; + name = Debug; + }; + 83CBBA211A601CBA00E9B192 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = YES; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 12.0; + LD_RUNPATH_SEARCH_PATHS = "/usr/lib/swift $(inherited)"; + LIBRARY_SEARCH_PATHS = "\\"$(inherited)\\""; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = iphoneos; + VALIDATE_PRODUCT = YES; + CODE_SIGN_ENTITLEMENTS = testapp/turtlev2.entitlements; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 13B07F931A680F5B00A75B9A /* Build configuration list for PBXNativeTarget "testapp" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 13B07F941A680F5B00A75B9A /* Debug */, + 13B07F951A680F5B00A75B9A /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 83CBB9FA1A601CBA00E9B192 /* Build configuration list for PBXProject "testapp" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 83CBBA201A601CBA00E9B192 /* Debug */, + 83CBBA211A601CBA00E9B192 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + }; + rootObject = 83CBB9F71A601CBA00E9B192 /* Project object */; +} +" +`; + +exports[`configureCredentialsAsync configures credentials for multi target project 1`] = ` +"// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 46; + objects = { + +/* Begin PBXBuildFile section */ + 13B07FBC1A68108700A75B9A /* AppDelegate.m in Sources */ = {isa = PBXBuildFile; fileRef = 13B07FB01A68108700A75B9A /* AppDelegate.m */; }; + 13B07FBD1A68108700A75B9A /* LaunchScreen.xib in Resources */ = {isa = PBXBuildFile; fileRef = 13B07FB11A68108700A75B9A /* LaunchScreen.xib */; }; + 13B07FBF1A68108700A75B9A /* Images.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 13B07FB51A68108700A75B9A /* Images.xcassets */; }; + 13B07FC11A68108700A75B9A /* main.m in Sources */ = {isa = PBXBuildFile; fileRef = 13B07FB71A68108700A75B9A /* main.m */; }; + 3E461D99554A48A4959DE609 /* SplashScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = AA286B85B6C04FC6940260E9 /* SplashScreen.storyboard */; }; + 6ADAD6E626493A420001F56E /* ShareViewController.m in Sources */ = {isa = PBXBuildFile; fileRef = 6ADAD6E526493A420001F56E /* ShareViewController.m */; }; + 6ADAD6E926493A420001F56E /* MainInterface.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 6ADAD6E726493A420001F56E /* MainInterface.storyboard */; }; + 6ADAD6ED26493A420001F56E /* shareextension.appex in Embed App Extensions */ = {isa = PBXBuildFile; fileRef = 6ADAD6E226493A420001F56E /* shareextension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; + 96905EF65AED1B983A6B3ABC /* libPods-multitarget.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 58EEBF8E8E6FB1BC6CAF49B5 /* libPods-multitarget.a */; }; + BB2F792D24A3F905000567C9 /* Expo.plist in Resources */ = {isa = PBXBuildFile; fileRef = BB2F792C24A3F905000567C9 /* Expo.plist */; }; +/* End PBXBuildFile section */ + +/* Begin PBXContainerItemProxy section */ + 6ADAD6EB26493A420001F56E /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 83CBB9F71A601CBA00E9B192 /* Project object */; + proxyType = 1; + remoteGlobalIDString = 6ADAD6E126493A420001F56E; + remoteInfo = shareextension; + }; +/* End PBXContainerItemProxy section */ + +/* Begin PBXCopyFilesBuildPhase section */ + 6ADAD6F126493A420001F56E /* Embed App Extensions */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 13; + files = ( + 6ADAD6ED26493A420001F56E /* shareextension.appex in Embed App Extensions */, + ); + name = "Embed App Extensions"; + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXCopyFilesBuildPhase section */ + +/* Begin PBXFileReference section */ + 008F07F21AC5B25A0029DE68 /* main.jsbundle */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text; path = main.jsbundle; sourceTree = ""; }; + 13B07F961A680F5B00A75B9A /* multitarget.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = multitarget.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 13B07FAF1A68108700A75B9A /* AppDelegate.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; name = AppDelegate.h; path = multitarget/AppDelegate.h; sourceTree = ""; }; + 13B07FB01A68108700A75B9A /* AppDelegate.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; name = AppDelegate.m; path = multitarget/AppDelegate.m; sourceTree = ""; }; + 13B07FB21A68108700A75B9A /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.xib; name = Base; path = Base.lproj/LaunchScreen.xib; sourceTree = ""; }; + 13B07FB51A68108700A75B9A /* Images.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; name = Images.xcassets; path = multitarget/Images.xcassets; sourceTree = ""; }; + 13B07FB61A68108700A75B9A /* Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = Info.plist; path = multitarget/Info.plist; sourceTree = ""; }; + 13B07FB71A68108700A75B9A /* main.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; name = main.m; path = multitarget/main.m; sourceTree = ""; }; + 58EEBF8E8E6FB1BC6CAF49B5 /* libPods-multitarget.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = "libPods-multitarget.a"; sourceTree = BUILT_PRODUCTS_DIR; }; + 6ADAD6E226493A420001F56E /* shareextension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = shareextension.appex; sourceTree = BUILT_PRODUCTS_DIR; }; + 6ADAD6E426493A420001F56E /* ShareViewController.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = ShareViewController.h; sourceTree = ""; }; + 6ADAD6E526493A420001F56E /* ShareViewController.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = ShareViewController.m; sourceTree = ""; }; + 6ADAD6E826493A420001F56E /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/MainInterface.storyboard; sourceTree = ""; }; + 6ADAD6EA26493A420001F56E /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + 6C2E3173556A471DD304B334 /* Pods-multitarget.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-multitarget.debug.xcconfig"; path = "Target Support Files/Pods-multitarget/Pods-multitarget.debug.xcconfig"; sourceTree = ""; }; + 7A4D352CD337FB3A3BF06240 /* Pods-multitarget.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-multitarget.release.xcconfig"; path = "Target Support Files/Pods-multitarget/Pods-multitarget.release.xcconfig"; sourceTree = ""; }; + AA286B85B6C04FC6940260E9 /* SplashScreen.storyboard */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.storyboard; name = SplashScreen.storyboard; path = multitarget/SplashScreen.storyboard; sourceTree = ""; }; + BB2F792C24A3F905000567C9 /* Expo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = Expo.plist; sourceTree = ""; }; + ED297162215061F000B7C4FE /* JavaScriptCore.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = JavaScriptCore.framework; path = System/Library/Frameworks/JavaScriptCore.framework; sourceTree = SDKROOT; }; + ED2971642150620600B7C4FE /* JavaScriptCore.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = JavaScriptCore.framework; path = Platforms/AppleTVOS.platform/Developer/SDKs/AppleTVOS12.0.sdk/System/Library/Frameworks/JavaScriptCore.framework; sourceTree = DEVELOPER_DIR; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + 13B07F8C1A680F5B00A75B9A /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 96905EF65AED1B983A6B3ABC /* libPods-multitarget.a in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 6ADAD6DF26493A420001F56E /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 13B07FAE1A68108700A75B9A /* multitarget */ = { + isa = PBXGroup; + children = ( + BB2F792B24A3F905000567C9 /* Supporting */, + 008F07F21AC5B25A0029DE68 /* main.jsbundle */, + 13B07FAF1A68108700A75B9A /* AppDelegate.h */, + 13B07FB01A68108700A75B9A /* AppDelegate.m */, + 13B07FB51A68108700A75B9A /* Images.xcassets */, + 13B07FB61A68108700A75B9A /* Info.plist */, + 13B07FB11A68108700A75B9A /* LaunchScreen.xib */, + 13B07FB71A68108700A75B9A /* main.m */, + AA286B85B6C04FC6940260E9 /* SplashScreen.storyboard */, + ); + name = multitarget; + sourceTree = ""; + }; + 2D16E6871FA4F8E400B85C8A /* Frameworks */ = { + isa = PBXGroup; + children = ( + ED297162215061F000B7C4FE /* JavaScriptCore.framework */, + ED2971642150620600B7C4FE /* JavaScriptCore.framework */, + 58EEBF8E8E6FB1BC6CAF49B5 /* libPods-multitarget.a */, + ); + name = Frameworks; + sourceTree = ""; + }; + 6ADAD6E326493A420001F56E /* shareextension */ = { + isa = PBXGroup; + children = ( + 6ADAD6E426493A420001F56E /* ShareViewController.h */, + 6ADAD6E526493A420001F56E /* ShareViewController.m */, + 6ADAD6E726493A420001F56E /* MainInterface.storyboard */, + 6ADAD6EA26493A420001F56E /* Info.plist */, + ); + path = shareextension; + sourceTree = ""; + }; + 832341AE1AAA6A7D00B99B32 /* Libraries */ = { + isa = PBXGroup; + children = ( + ); + name = Libraries; + sourceTree = ""; + }; + 83CBB9F61A601CBA00E9B192 = { + isa = PBXGroup; + children = ( + 13B07FAE1A68108700A75B9A /* multitarget */, + 832341AE1AAA6A7D00B99B32 /* Libraries */, + 6ADAD6E326493A420001F56E /* shareextension */, + 83CBBA001A601CBA00E9B192 /* Products */, + 2D16E6871FA4F8E400B85C8A /* Frameworks */, + D65327D7A22EEC0BE12398D9 /* Pods */, + ); + indentWidth = 2; + sourceTree = ""; + tabWidth = 2; + usesTabs = 0; + }; + 83CBBA001A601CBA00E9B192 /* Products */ = { + isa = PBXGroup; + children = ( + 13B07F961A680F5B00A75B9A /* multitarget.app */, + 6ADAD6E226493A420001F56E /* shareextension.appex */, + ); + name = Products; + sourceTree = ""; + }; + BB2F792B24A3F905000567C9 /* Supporting */ = { + isa = PBXGroup; + children = ( + BB2F792C24A3F905000567C9 /* Expo.plist */, + ); + name = Supporting; + path = multitarget/Supporting; + sourceTree = ""; + }; + D65327D7A22EEC0BE12398D9 /* Pods */ = { + isa = PBXGroup; + children = ( + 6C2E3173556A471DD304B334 /* Pods-multitarget.debug.xcconfig */, + 7A4D352CD337FB3A3BF06240 /* Pods-multitarget.release.xcconfig */, + ); + path = Pods; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 13B07F861A680F5B00A75B9A /* multitarget */ = { + isa = PBXNativeTarget; + buildConfigurationList = 13B07F931A680F5B00A75B9A /* Build configuration list for PBXNativeTarget "multitarget" */; + buildPhases = ( + 08A4A3CD28434E44B6B9DE2E /* [CP] Check Pods Manifest.lock */, + FD10A7F022414F080027D42C /* Start Packager */, + 13B07F871A680F5B00A75B9A /* Sources */, + 13B07F8C1A680F5B00A75B9A /* Frameworks */, + 13B07F8E1A680F5B00A75B9A /* Resources */, + 00DD1BFF1BD5951E006B06BC /* Bundle React Native code and images */, + AF7BF8C823CE838DA4182532 /* [CP] Copy Pods Resources */, + 6ADAD6F126493A420001F56E /* Embed App Extensions */, + ); + buildRules = ( + ); + dependencies = ( + 6ADAD6EC26493A420001F56E /* PBXTargetDependency */, + ); + name = multitarget; + productName = multitarget; + productReference = 13B07F961A680F5B00A75B9A /* multitarget.app */; + productType = "com.apple.product-type.application"; + }; + 6ADAD6E126493A420001F56E /* shareextension */ = { + isa = PBXNativeTarget; + buildConfigurationList = 6ADAD6EE26493A420001F56E /* Build configuration list for PBXNativeTarget "shareextension" */; + buildPhases = ( + 6ADAD6DE26493A420001F56E /* Sources */, + 6ADAD6DF26493A420001F56E /* Frameworks */, + 6ADAD6E026493A420001F56E /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = shareextension; + productName = shareextension; + productReference = 6ADAD6E226493A420001F56E /* shareextension.appex */; + productType = "com.apple.product-type.app-extension"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 83CBB9F71A601CBA00E9B192 /* Project object */ = { + isa = PBXProject; + attributes = { + LastUpgradeCheck = 1130; + TargetAttributes = { + 13B07F861A680F5B00A75B9A = { + DevelopmentTeam = "ABCDEFGH"; + LastSwiftMigration = 1120; + ProvisioningStyle = Manual; + }; + 6ADAD6E126493A420001F56E = { + CreatedOnToolsVersion = 12.5; + DevelopmentTeam = "ABCDEFGH"; + ProvisioningStyle = Manual; + }; + }; + }; + buildConfigurationList = 83CBB9FA1A601CBA00E9B192 /* Build configuration list for PBXProject "multitarget" */; + compatibilityVersion = "Xcode 3.2"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = 83CBB9F61A601CBA00E9B192; + productRefGroup = 83CBBA001A601CBA00E9B192 /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 13B07F861A680F5B00A75B9A /* multitarget */, + 6ADAD6E126493A420001F56E /* shareextension */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 13B07F8E1A680F5B00A75B9A /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + BB2F792D24A3F905000567C9 /* Expo.plist in Resources */, + 13B07FBF1A68108700A75B9A /* Images.xcassets in Resources */, + 13B07FBD1A68108700A75B9A /* LaunchScreen.xib in Resources */, + 3E461D99554A48A4959DE609 /* SplashScreen.storyboard in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 6ADAD6E026493A420001F56E /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 6ADAD6E926493A420001F56E /* MainInterface.storyboard in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXShellScriptBuildPhase section */ + 00DD1BFF1BD5951E006B06BC /* Bundle React Native code and images */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + ); + name = "Bundle React Native code and images"; + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "if [[ -f /Users/dsokal/.volta/bin/node ]]; then\\n export NODE_BINARY=/Users/dsokal/.volta/bin/node\\nelse\\n export NODE_BINARY=node\\nfi\\n../node_modules/react-native/scripts/react-native-xcode.sh\\n../node_modules/expo-constants/scripts/get-app-config-ios.sh\\n../node_modules/expo-updates/scripts/create-manifest-ios.sh\\n"; + }; + 08A4A3CD28434E44B6B9DE2E /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "\${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "\${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-multitarget-checkManifestLockResult.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "diff \\"\${PODS_PODFILE_DIR_PATH}/Podfile.lock\\" \\"\${PODS_ROOT}/Manifest.lock\\" > /dev/null\\nif [ $? != 0 ] ; then\\n # print error to STDERR\\n echo \\"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\\" >&2\\n exit 1\\nfi\\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\\necho \\"SUCCESS\\" > \\"\${SCRIPT_OUTPUT_FILE_0}\\"\\n"; + showEnvVarsInLog = 0; + }; + AF7BF8C823CE838DA4182532 /* [CP] Copy Pods Resources */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + "\${PODS_ROOT}/Target Support Files/Pods-multitarget/Pods-multitarget-resources.sh", + "\${PODS_CONFIGURATION_BUILD_DIR}/React-Core/AccessibilityResources.bundle", + ); + name = "[CP] Copy Pods Resources"; + outputPaths = ( + "\${TARGET_BUILD_DIR}/\${UNLOCALIZED_RESOURCES_FOLDER_PATH}/AccessibilityResources.bundle", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\\"\${PODS_ROOT}/Target Support Files/Pods-multitarget/Pods-multitarget-resources.sh\\"\\n"; + showEnvVarsInLog = 0; + }; + FD10A7F022414F080027D42C /* Start Packager */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + ); + name = "Start Packager"; + outputFileListPaths = ( + ); + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "export RCT_METRO_PORT=\\"\${RCT_METRO_PORT:=8081}\\"\\necho \\"export RCT_METRO_PORT=\${RCT_METRO_PORT}\\" > \\"\${SRCROOT}/../node_modules/react-native/scripts/.packager.env\\"\\nif [ -z \\"\${RCT_NO_LAUNCH_PACKAGER+xxx}\\" ] ; then\\n if nc -w 5 -z localhost \${RCT_METRO_PORT} ; then\\n if ! curl -s \\"http://localhost:\${RCT_METRO_PORT}/status\\" | grep -q \\"packager-status:running\\" ; then\\n echo \\"Port \${RCT_METRO_PORT} already in use, packager is either not running or not running correctly\\"\\n exit 2\\n fi\\n else\\n open \\"$SRCROOT/../node_modules/react-native/scripts/launchPackager.command\\" || echo \\"Can't start packager automatically\\"\\n fi\\nfi\\n"; + showEnvVarsInLog = 0; + }; +/* End PBXShellScriptBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 13B07F871A680F5B00A75B9A /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 13B07FBC1A68108700A75B9A /* AppDelegate.m in Sources */, + 13B07FC11A68108700A75B9A /* main.m in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 6ADAD6DE26493A420001F56E /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 6ADAD6E626493A420001F56E /* ShareViewController.m in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXTargetDependency section */ + 6ADAD6EC26493A420001F56E /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 6ADAD6E126493A420001F56E /* shareextension */; + targetProxy = 6ADAD6EB26493A420001F56E /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + +/* Begin PBXVariantGroup section */ + 13B07FB11A68108700A75B9A /* LaunchScreen.xib */ = { + isa = PBXVariantGroup; + children = ( + 13B07FB21A68108700A75B9A /* Base */, + ); + name = LaunchScreen.xib; + path = multitarget; + sourceTree = ""; + }; + 6ADAD6E726493A420001F56E /* MainInterface.storyboard */ = { + isa = PBXVariantGroup; + children = ( + 6ADAD6E826493A420001F56E /* Base */, + ); + name = MainInterface.storyboard; + sourceTree = ""; + }; +/* End PBXVariantGroup section */ + +/* Begin XCBuildConfiguration section */ + 13B07F941A680F5B00A75B9A /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 6C2E3173556A471DD304B334 /* Pods-multitarget.debug.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = QL76XYH73P; + ENABLE_BITCODE = NO; + GCC_PREPROCESSOR_DEFINITIONS = ( + "$(inherited)", + "FB_SONARKIT_ENABLED=1", + ); + INFOPLIST_FILE = multitarget/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 11.0; + LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; + OTHER_LDFLAGS = ( + "$(inherited)", + "-ObjC", + "-lc++", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.swmansion.dominik.multitarget.debug; + PRODUCT_NAME = multitarget; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = Debug; + }; + 13B07F951A680F5B00A75B9A /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7A4D352CD337FB3A3BF06240 /* Pods-multitarget.release.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_IDENTITY = "iPhone Distribution"; + CODE_SIGN_STYLE = Manual; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = "ABCDEFGH"; + INFOPLIST_FILE = multitarget/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 11.0; + LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; + OTHER_LDFLAGS = ( + "$(inherited)", + "-ObjC", + "-lc++", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.swmansion.dominik.multitarget; + PRODUCT_NAME = multitarget; + PROVISIONING_PROFILE_SPECIFIER = "multitarget profile"; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = Release; + }; + 6ADAD6EF26493A420001F56E /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CODE_SIGN_STYLE = Automatic; + DEBUG_INFORMATION_FORMAT = dwarf; + DEVELOPMENT_TEAM = QL76XYH73P; + GCC_C_LANGUAGE_STANDARD = gnu11; + INFOPLIST_FILE = shareextension/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 14.5; + LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @executable_path/../../Frameworks"; + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + MTL_FAST_MATH = YES; + PRODUCT_BUNDLE_IDENTIFIER = com.swmansion.dominik.multitarget.shareextension; + PRODUCT_NAME = "$(TARGET_NAME)"; + SKIP_INSTALL = YES; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + 6ADAD6F026493A420001F56E /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CODE_SIGN_STYLE = Manual; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + DEVELOPMENT_TEAM = "ABCDEFGH"; + GCC_C_LANGUAGE_STANDARD = gnu11; + INFOPLIST_FILE = shareextension/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 14.5; + LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @executable_path/../../Frameworks"; + MTL_FAST_MATH = YES; + PRODUCT_BUNDLE_IDENTIFIER = com.swmansion.dominik.multitarget.shareextension; + PRODUCT_NAME = "$(TARGET_NAME)"; + SKIP_INSTALL = YES; + TARGETED_DEVICE_FAMILY = "1,2"; + PROVISIONING_PROFILE_SPECIFIER = "extension profile"; + CODE_SIGN_IDENTITY = "iPhone Distribution"; + }; + name = Release; + }; + 83CBBA201A601CBA00E9B192 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_SYMBOLS_PRIVATE_EXTERN = NO; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 11.0; + LD_RUNPATH_SEARCH_PATHS = "/usr/lib/swift $(inherited)"; + LIBRARY_SEARCH_PATHS = ( + "\\"$(TOOLCHAIN_DIR)/usr/lib/swift/$(PLATFORM_NAME)\\"", + "\\"$(TOOLCHAIN_DIR)/usr/lib/swift-5.0/$(PLATFORM_NAME)\\"", + "\\"$(inherited)\\"", + ); + MTL_ENABLE_DEBUG_INFO = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = iphoneos; + }; + name = Debug; + }; + 83CBBA211A601CBA00E9B192 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = YES; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 11.0; + LD_RUNPATH_SEARCH_PATHS = "/usr/lib/swift $(inherited)"; + LIBRARY_SEARCH_PATHS = ( + "\\"$(TOOLCHAIN_DIR)/usr/lib/swift/$(PLATFORM_NAME)\\"", + "\\"$(TOOLCHAIN_DIR)/usr/lib/swift-5.0/$(PLATFORM_NAME)\\"", + "\\"$(inherited)\\"", + ); + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = iphoneos; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 13B07F931A680F5B00A75B9A /* Build configuration list for PBXNativeTarget "multitarget" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 13B07F941A680F5B00A75B9A /* Debug */, + 13B07F951A680F5B00A75B9A /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 6ADAD6EE26493A420001F56E /* Build configuration list for PBXNativeTarget "shareextension" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 6ADAD6EF26493A420001F56E /* Debug */, + 6ADAD6F026493A420001F56E /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 83CBB9FA1A601CBA00E9B192 /* Build configuration list for PBXProject "multitarget" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 83CBBA201A601CBA00E9B192 /* Debug */, + 83CBBA211A601CBA00E9B192 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + }; + rootObject = 83CBB9F71A601CBA00E9B192 /* Project object */; +} +" +`; + +exports[`updateVersionsAsync configures credentials and versions for multi target project 1`] = ` +"// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 46; + objects = { + +/* Begin PBXBuildFile section */ + 13B07FBC1A68108700A75B9A /* AppDelegate.m in Sources */ = {isa = PBXBuildFile; fileRef = 13B07FB01A68108700A75B9A /* AppDelegate.m */; }; + 13B07FBD1A68108700A75B9A /* LaunchScreen.xib in Resources */ = {isa = PBXBuildFile; fileRef = 13B07FB11A68108700A75B9A /* LaunchScreen.xib */; }; + 13B07FBF1A68108700A75B9A /* Images.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 13B07FB51A68108700A75B9A /* Images.xcassets */; }; + 13B07FC11A68108700A75B9A /* main.m in Sources */ = {isa = PBXBuildFile; fileRef = 13B07FB71A68108700A75B9A /* main.m */; }; + 3E461D99554A48A4959DE609 /* SplashScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = AA286B85B6C04FC6940260E9 /* SplashScreen.storyboard */; }; + 6ADAD6E626493A420001F56E /* ShareViewController.m in Sources */ = {isa = PBXBuildFile; fileRef = 6ADAD6E526493A420001F56E /* ShareViewController.m */; }; + 6ADAD6E926493A420001F56E /* MainInterface.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 6ADAD6E726493A420001F56E /* MainInterface.storyboard */; }; + 6ADAD6ED26493A420001F56E /* shareextension.appex in Embed App Extensions */ = {isa = PBXBuildFile; fileRef = 6ADAD6E226493A420001F56E /* shareextension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; + 96905EF65AED1B983A6B3ABC /* libPods-multitarget.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 58EEBF8E8E6FB1BC6CAF49B5 /* libPods-multitarget.a */; }; + BB2F792D24A3F905000567C9 /* Expo.plist in Resources */ = {isa = PBXBuildFile; fileRef = BB2F792C24A3F905000567C9 /* Expo.plist */; }; +/* End PBXBuildFile section */ + +/* Begin PBXContainerItemProxy section */ + 6ADAD6EB26493A420001F56E /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 83CBB9F71A601CBA00E9B192 /* Project object */; + proxyType = 1; + remoteGlobalIDString = 6ADAD6E126493A420001F56E; + remoteInfo = shareextension; + }; +/* End PBXContainerItemProxy section */ + +/* Begin PBXCopyFilesBuildPhase section */ + 6ADAD6F126493A420001F56E /* Embed App Extensions */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 13; + files = ( + 6ADAD6ED26493A420001F56E /* shareextension.appex in Embed App Extensions */, + ); + name = "Embed App Extensions"; + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXCopyFilesBuildPhase section */ + +/* Begin PBXFileReference section */ + 008F07F21AC5B25A0029DE68 /* main.jsbundle */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text; path = main.jsbundle; sourceTree = ""; }; + 13B07F961A680F5B00A75B9A /* multitarget.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = multitarget.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 13B07FAF1A68108700A75B9A /* AppDelegate.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; name = AppDelegate.h; path = multitarget/AppDelegate.h; sourceTree = ""; }; + 13B07FB01A68108700A75B9A /* AppDelegate.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; name = AppDelegate.m; path = multitarget/AppDelegate.m; sourceTree = ""; }; + 13B07FB21A68108700A75B9A /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.xib; name = Base; path = Base.lproj/LaunchScreen.xib; sourceTree = ""; }; + 13B07FB51A68108700A75B9A /* Images.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; name = Images.xcassets; path = multitarget/Images.xcassets; sourceTree = ""; }; + 13B07FB61A68108700A75B9A /* Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = Info.plist; path = multitarget/Info.plist; sourceTree = ""; }; + 13B07FB71A68108700A75B9A /* main.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; name = main.m; path = multitarget/main.m; sourceTree = ""; }; + 58EEBF8E8E6FB1BC6CAF49B5 /* libPods-multitarget.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = "libPods-multitarget.a"; sourceTree = BUILT_PRODUCTS_DIR; }; + 6ADAD6E226493A420001F56E /* shareextension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = shareextension.appex; sourceTree = BUILT_PRODUCTS_DIR; }; + 6ADAD6E426493A420001F56E /* ShareViewController.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = ShareViewController.h; sourceTree = ""; }; + 6ADAD6E526493A420001F56E /* ShareViewController.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = ShareViewController.m; sourceTree = ""; }; + 6ADAD6E826493A420001F56E /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/MainInterface.storyboard; sourceTree = ""; }; + 6ADAD6EA26493A420001F56E /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + 6C2E3173556A471DD304B334 /* Pods-multitarget.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-multitarget.debug.xcconfig"; path = "Target Support Files/Pods-multitarget/Pods-multitarget.debug.xcconfig"; sourceTree = ""; }; + 7A4D352CD337FB3A3BF06240 /* Pods-multitarget.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-multitarget.release.xcconfig"; path = "Target Support Files/Pods-multitarget/Pods-multitarget.release.xcconfig"; sourceTree = ""; }; + AA286B85B6C04FC6940260E9 /* SplashScreen.storyboard */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.storyboard; name = SplashScreen.storyboard; path = multitarget/SplashScreen.storyboard; sourceTree = ""; }; + BB2F792C24A3F905000567C9 /* Expo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = Expo.plist; sourceTree = ""; }; + ED297162215061F000B7C4FE /* JavaScriptCore.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = JavaScriptCore.framework; path = System/Library/Frameworks/JavaScriptCore.framework; sourceTree = SDKROOT; }; + ED2971642150620600B7C4FE /* JavaScriptCore.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = JavaScriptCore.framework; path = Platforms/AppleTVOS.platform/Developer/SDKs/AppleTVOS12.0.sdk/System/Library/Frameworks/JavaScriptCore.framework; sourceTree = DEVELOPER_DIR; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + 13B07F8C1A680F5B00A75B9A /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 96905EF65AED1B983A6B3ABC /* libPods-multitarget.a in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 6ADAD6DF26493A420001F56E /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 13B07FAE1A68108700A75B9A /* multitarget */ = { + isa = PBXGroup; + children = ( + BB2F792B24A3F905000567C9 /* Supporting */, + 008F07F21AC5B25A0029DE68 /* main.jsbundle */, + 13B07FAF1A68108700A75B9A /* AppDelegate.h */, + 13B07FB01A68108700A75B9A /* AppDelegate.m */, + 13B07FB51A68108700A75B9A /* Images.xcassets */, + 13B07FB61A68108700A75B9A /* Info.plist */, + 13B07FB11A68108700A75B9A /* LaunchScreen.xib */, + 13B07FB71A68108700A75B9A /* main.m */, + AA286B85B6C04FC6940260E9 /* SplashScreen.storyboard */, + ); + name = multitarget; + sourceTree = ""; + }; + 2D16E6871FA4F8E400B85C8A /* Frameworks */ = { + isa = PBXGroup; + children = ( + ED297162215061F000B7C4FE /* JavaScriptCore.framework */, + ED2971642150620600B7C4FE /* JavaScriptCore.framework */, + 58EEBF8E8E6FB1BC6CAF49B5 /* libPods-multitarget.a */, + ); + name = Frameworks; + sourceTree = ""; + }; + 6ADAD6E326493A420001F56E /* shareextension */ = { + isa = PBXGroup; + children = ( + 6ADAD6E426493A420001F56E /* ShareViewController.h */, + 6ADAD6E526493A420001F56E /* ShareViewController.m */, + 6ADAD6E726493A420001F56E /* MainInterface.storyboard */, + 6ADAD6EA26493A420001F56E /* Info.plist */, + ); + path = shareextension; + sourceTree = ""; + }; + 832341AE1AAA6A7D00B99B32 /* Libraries */ = { + isa = PBXGroup; + children = ( + ); + name = Libraries; + sourceTree = ""; + }; + 83CBB9F61A601CBA00E9B192 = { + isa = PBXGroup; + children = ( + 13B07FAE1A68108700A75B9A /* multitarget */, + 832341AE1AAA6A7D00B99B32 /* Libraries */, + 6ADAD6E326493A420001F56E /* shareextension */, + 83CBBA001A601CBA00E9B192 /* Products */, + 2D16E6871FA4F8E400B85C8A /* Frameworks */, + D65327D7A22EEC0BE12398D9 /* Pods */, + ); + indentWidth = 2; + sourceTree = ""; + tabWidth = 2; + usesTabs = 0; + }; + 83CBBA001A601CBA00E9B192 /* Products */ = { + isa = PBXGroup; + children = ( + 13B07F961A680F5B00A75B9A /* multitarget.app */, + 6ADAD6E226493A420001F56E /* shareextension.appex */, + ); + name = Products; + sourceTree = ""; + }; + BB2F792B24A3F905000567C9 /* Supporting */ = { + isa = PBXGroup; + children = ( + BB2F792C24A3F905000567C9 /* Expo.plist */, + ); + name = Supporting; + path = multitarget/Supporting; + sourceTree = ""; + }; + D65327D7A22EEC0BE12398D9 /* Pods */ = { + isa = PBXGroup; + children = ( + 6C2E3173556A471DD304B334 /* Pods-multitarget.debug.xcconfig */, + 7A4D352CD337FB3A3BF06240 /* Pods-multitarget.release.xcconfig */, + ); + path = Pods; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 13B07F861A680F5B00A75B9A /* multitarget */ = { + isa = PBXNativeTarget; + buildConfigurationList = 13B07F931A680F5B00A75B9A /* Build configuration list for PBXNativeTarget "multitarget" */; + buildPhases = ( + 08A4A3CD28434E44B6B9DE2E /* [CP] Check Pods Manifest.lock */, + FD10A7F022414F080027D42C /* Start Packager */, + 13B07F871A680F5B00A75B9A /* Sources */, + 13B07F8C1A680F5B00A75B9A /* Frameworks */, + 13B07F8E1A680F5B00A75B9A /* Resources */, + 00DD1BFF1BD5951E006B06BC /* Bundle React Native code and images */, + AF7BF8C823CE838DA4182532 /* [CP] Copy Pods Resources */, + 6ADAD6F126493A420001F56E /* Embed App Extensions */, + ); + buildRules = ( + ); + dependencies = ( + 6ADAD6EC26493A420001F56E /* PBXTargetDependency */, + ); + name = multitarget; + productName = multitarget; + productReference = 13B07F961A680F5B00A75B9A /* multitarget.app */; + productType = "com.apple.product-type.application"; + }; + 6ADAD6E126493A420001F56E /* shareextension */ = { + isa = PBXNativeTarget; + buildConfigurationList = 6ADAD6EE26493A420001F56E /* Build configuration list for PBXNativeTarget "shareextension" */; + buildPhases = ( + 6ADAD6DE26493A420001F56E /* Sources */, + 6ADAD6DF26493A420001F56E /* Frameworks */, + 6ADAD6E026493A420001F56E /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = shareextension; + productName = shareextension; + productReference = 6ADAD6E226493A420001F56E /* shareextension.appex */; + productType = "com.apple.product-type.app-extension"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 83CBB9F71A601CBA00E9B192 /* Project object */ = { + isa = PBXProject; + attributes = { + LastUpgradeCheck = 1130; + TargetAttributes = { + 13B07F861A680F5B00A75B9A = { + DevelopmentTeam = "ABCDEFGH"; + LastSwiftMigration = 1120; + ProvisioningStyle = Manual; + }; + 6ADAD6E126493A420001F56E = { + CreatedOnToolsVersion = 12.5; + DevelopmentTeam = "ABCDEFGH"; + ProvisioningStyle = Manual; + }; + }; + }; + buildConfigurationList = 83CBB9FA1A601CBA00E9B192 /* Build configuration list for PBXProject "multitarget" */; + compatibilityVersion = "Xcode 3.2"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = 83CBB9F61A601CBA00E9B192; + productRefGroup = 83CBBA001A601CBA00E9B192 /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 13B07F861A680F5B00A75B9A /* multitarget */, + 6ADAD6E126493A420001F56E /* shareextension */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 13B07F8E1A680F5B00A75B9A /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + BB2F792D24A3F905000567C9 /* Expo.plist in Resources */, + 13B07FBF1A68108700A75B9A /* Images.xcassets in Resources */, + 13B07FBD1A68108700A75B9A /* LaunchScreen.xib in Resources */, + 3E461D99554A48A4959DE609 /* SplashScreen.storyboard in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 6ADAD6E026493A420001F56E /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 6ADAD6E926493A420001F56E /* MainInterface.storyboard in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXShellScriptBuildPhase section */ + 00DD1BFF1BD5951E006B06BC /* Bundle React Native code and images */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + ); + name = "Bundle React Native code and images"; + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "if [[ -f /Users/dsokal/.volta/bin/node ]]; then\\n export NODE_BINARY=/Users/dsokal/.volta/bin/node\\nelse\\n export NODE_BINARY=node\\nfi\\n../node_modules/react-native/scripts/react-native-xcode.sh\\n../node_modules/expo-constants/scripts/get-app-config-ios.sh\\n../node_modules/expo-updates/scripts/create-manifest-ios.sh\\n"; + }; + 08A4A3CD28434E44B6B9DE2E /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "\${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "\${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-multitarget-checkManifestLockResult.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "diff \\"\${PODS_PODFILE_DIR_PATH}/Podfile.lock\\" \\"\${PODS_ROOT}/Manifest.lock\\" > /dev/null\\nif [ $? != 0 ] ; then\\n # print error to STDERR\\n echo \\"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\\" >&2\\n exit 1\\nfi\\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\\necho \\"SUCCESS\\" > \\"\${SCRIPT_OUTPUT_FILE_0}\\"\\n"; + showEnvVarsInLog = 0; + }; + AF7BF8C823CE838DA4182532 /* [CP] Copy Pods Resources */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + "\${PODS_ROOT}/Target Support Files/Pods-multitarget/Pods-multitarget-resources.sh", + "\${PODS_CONFIGURATION_BUILD_DIR}/React-Core/AccessibilityResources.bundle", + ); + name = "[CP] Copy Pods Resources"; + outputPaths = ( + "\${TARGET_BUILD_DIR}/\${UNLOCALIZED_RESOURCES_FOLDER_PATH}/AccessibilityResources.bundle", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\\"\${PODS_ROOT}/Target Support Files/Pods-multitarget/Pods-multitarget-resources.sh\\"\\n"; + showEnvVarsInLog = 0; + }; + FD10A7F022414F080027D42C /* Start Packager */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + ); + name = "Start Packager"; + outputFileListPaths = ( + ); + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "export RCT_METRO_PORT=\\"\${RCT_METRO_PORT:=8081}\\"\\necho \\"export RCT_METRO_PORT=\${RCT_METRO_PORT}\\" > \\"\${SRCROOT}/../node_modules/react-native/scripts/.packager.env\\"\\nif [ -z \\"\${RCT_NO_LAUNCH_PACKAGER+xxx}\\" ] ; then\\n if nc -w 5 -z localhost \${RCT_METRO_PORT} ; then\\n if ! curl -s \\"http://localhost:\${RCT_METRO_PORT}/status\\" | grep -q \\"packager-status:running\\" ; then\\n echo \\"Port \${RCT_METRO_PORT} already in use, packager is either not running or not running correctly\\"\\n exit 2\\n fi\\n else\\n open \\"$SRCROOT/../node_modules/react-native/scripts/launchPackager.command\\" || echo \\"Can't start packager automatically\\"\\n fi\\nfi\\n"; + showEnvVarsInLog = 0; + }; +/* End PBXShellScriptBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 13B07F871A680F5B00A75B9A /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 13B07FBC1A68108700A75B9A /* AppDelegate.m in Sources */, + 13B07FC11A68108700A75B9A /* main.m in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 6ADAD6DE26493A420001F56E /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 6ADAD6E626493A420001F56E /* ShareViewController.m in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXTargetDependency section */ + 6ADAD6EC26493A420001F56E /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 6ADAD6E126493A420001F56E /* shareextension */; + targetProxy = 6ADAD6EB26493A420001F56E /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + +/* Begin PBXVariantGroup section */ + 13B07FB11A68108700A75B9A /* LaunchScreen.xib */ = { + isa = PBXVariantGroup; + children = ( + 13B07FB21A68108700A75B9A /* Base */, + ); + name = LaunchScreen.xib; + path = multitarget; + sourceTree = ""; + }; + 6ADAD6E726493A420001F56E /* MainInterface.storyboard */ = { + isa = PBXVariantGroup; + children = ( + 6ADAD6E826493A420001F56E /* Base */, + ); + name = MainInterface.storyboard; + sourceTree = ""; + }; +/* End PBXVariantGroup section */ + +/* Begin XCBuildConfiguration section */ + 13B07F941A680F5B00A75B9A /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 6C2E3173556A471DD304B334 /* Pods-multitarget.debug.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = QL76XYH73P; + ENABLE_BITCODE = NO; + GCC_PREPROCESSOR_DEFINITIONS = ( + "$(inherited)", + "FB_SONARKIT_ENABLED=1", + ); + INFOPLIST_FILE = multitarget/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 11.0; + LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; + OTHER_LDFLAGS = ( + "$(inherited)", + "-ObjC", + "-lc++", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.swmansion.dominik.multitarget.debug; + PRODUCT_NAME = multitarget; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = Debug; + }; + 13B07F951A680F5B00A75B9A /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7A4D352CD337FB3A3BF06240 /* Pods-multitarget.release.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_IDENTITY = "iPhone Distribution"; + CODE_SIGN_STYLE = Manual; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = "ABCDEFGH"; + INFOPLIST_FILE = multitarget/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 11.0; + LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; + OTHER_LDFLAGS = ( + "$(inherited)", + "-ObjC", + "-lc++", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.swmansion.dominik.multitarget; + PRODUCT_NAME = multitarget; + PROVISIONING_PROFILE_SPECIFIER = "multitarget profile"; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = Release; + }; + 6ADAD6EF26493A420001F56E /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CODE_SIGN_STYLE = Automatic; + DEBUG_INFORMATION_FORMAT = dwarf; + DEVELOPMENT_TEAM = QL76XYH73P; + GCC_C_LANGUAGE_STANDARD = gnu11; + INFOPLIST_FILE = shareextension/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 14.5; + LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @executable_path/../../Frameworks"; + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + MTL_FAST_MATH = YES; + PRODUCT_BUNDLE_IDENTIFIER = com.swmansion.dominik.multitarget.shareextension; + PRODUCT_NAME = "$(TARGET_NAME)"; + SKIP_INSTALL = YES; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + 6ADAD6F026493A420001F56E /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CODE_SIGN_STYLE = Manual; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + DEVELOPMENT_TEAM = "ABCDEFGH"; + GCC_C_LANGUAGE_STANDARD = gnu11; + INFOPLIST_FILE = shareextension/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 14.5; + LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @executable_path/../../Frameworks"; + MTL_FAST_MATH = YES; + PRODUCT_BUNDLE_IDENTIFIER = com.swmansion.dominik.multitarget.shareextension; + PRODUCT_NAME = "$(TARGET_NAME)"; + SKIP_INSTALL = YES; + TARGETED_DEVICE_FAMILY = "1,2"; + PROVISIONING_PROFILE_SPECIFIER = "extension profile"; + CODE_SIGN_IDENTITY = "iPhone Distribution"; + }; + name = Release; + }; + 83CBBA201A601CBA00E9B192 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_SYMBOLS_PRIVATE_EXTERN = NO; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 11.0; + LD_RUNPATH_SEARCH_PATHS = "/usr/lib/swift $(inherited)"; + LIBRARY_SEARCH_PATHS = ( + "\\"$(TOOLCHAIN_DIR)/usr/lib/swift/$(PLATFORM_NAME)\\"", + "\\"$(TOOLCHAIN_DIR)/usr/lib/swift-5.0/$(PLATFORM_NAME)\\"", + "\\"$(inherited)\\"", + ); + MTL_ENABLE_DEBUG_INFO = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = iphoneos; + }; + name = Debug; + }; + 83CBBA211A601CBA00E9B192 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = YES; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 11.0; + LD_RUNPATH_SEARCH_PATHS = "/usr/lib/swift $(inherited)"; + LIBRARY_SEARCH_PATHS = ( + "\\"$(TOOLCHAIN_DIR)/usr/lib/swift/$(PLATFORM_NAME)\\"", + "\\"$(TOOLCHAIN_DIR)/usr/lib/swift-5.0/$(PLATFORM_NAME)\\"", + "\\"$(inherited)\\"", + ); + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = iphoneos; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 13B07F931A680F5B00A75B9A /* Build configuration list for PBXNativeTarget "multitarget" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 13B07F941A680F5B00A75B9A /* Debug */, + 13B07F951A680F5B00A75B9A /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 6ADAD6EE26493A420001F56E /* Build configuration list for PBXNativeTarget "shareextension" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 6ADAD6EF26493A420001F56E /* Debug */, + 6ADAD6F026493A420001F56E /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 83CBB9FA1A601CBA00E9B192 /* Build configuration list for PBXProject "multitarget" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 83CBBA201A601CBA00E9B192 /* Debug */, + 83CBBA211A601CBA00E9B192 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + }; + rootObject = 83CBB9F71A601CBA00E9B192 /* Project object */; +} +" +`; + +exports[`updateVersionsAsync configures credentials and versions for multi target project: Info.plist application target 1`] = ` +" + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + testapp + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + $(PRODUCT_BUNDLE_PACKAGE_TYPE) + CFBundleShortVersionString + 1.2.3 + CFBundleSignature + ???? + CFBundleURLTypes + + + CFBundleURLSchemes + + org.reactjs.native.example.testapp.turtlev2 + + + + CFBundleVersion + 1.2.4 + LSRequiresIPhoneOS + + NSAppTransportSecurity + + NSAllowsArbitraryLoads + + NSExceptionDomains + + localhost + + NSExceptionAllowsInsecureHTTPLoads + + + + + UILaunchStoryboardName + SplashScreen + UIRequiredDeviceCapabilities + + armv7 + + UIRequiresFullScreen + + UIStatusBarStyle + UIStatusBarStyleDefault + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + + UISupportedInterfaceOrientations~ipad + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UIUserInterfaceStyle + Light + UIViewControllerBasedStatusBarAppearance + + +" +`; + +exports[`updateVersionsAsync configures credentials and versions for multi target project: Info.plist extension 1`] = ` +" + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + testapp + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + $(PRODUCT_BUNDLE_PACKAGE_TYPE) + CFBundleShortVersionString + 1.2.3 + CFBundleSignature + ???? + CFBundleURLTypes + + + CFBundleURLSchemes + + org.reactjs.native.example.testapp.turtlev2 + + + + CFBundleVersion + 1.2.4 + LSRequiresIPhoneOS + + NSAppTransportSecurity + + NSAllowsArbitraryLoads + + NSExceptionDomains + + localhost + + NSExceptionAllowsInsecureHTTPLoads + + + + + UILaunchStoryboardName + SplashScreen + UIRequiredDeviceCapabilities + + armv7 + + UIRequiresFullScreen + + UIStatusBarStyle + UIStatusBarStyleDefault + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + + UISupportedInterfaceOrientations~ipad + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UIUserInterfaceStyle + Light + UIViewControllerBasedStatusBarAppearance + + +" +`; + +exports[`updateVersionsAsync configures versions for a simple project 1`] = ` +"// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 46; + objects = { + +/* Begin PBXBuildFile section */ + 13B07FBC1A68108700A75B9A /* AppDelegate.m in Sources */ = {isa = PBXBuildFile; fileRef = 13B07FB01A68108700A75B9A /* AppDelegate.m */; }; + 13B07FBF1A68108700A75B9A /* Images.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 13B07FB51A68108700A75B9A /* Images.xcassets */; }; + 13B07FC11A68108700A75B9A /* main.m in Sources */ = {isa = PBXBuildFile; fileRef = 13B07FB71A68108700A75B9A /* main.m */; }; + 3E461D99554A48A4959DE609 /* SplashScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = AA286B85B6C04FC6940260E9 /* SplashScreen.storyboard */; }; + 96905EF65AED1B983A6B3ABC /* libPods-testapp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 58EEBF8E8E6FB1BC6CAF49B5 /* libPods-testapp.a */; }; + B18059E884C0ABDD17F3DC3D /* ExpoModulesProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = FAC715A2D49A985799AEE119 /* ExpoModulesProvider.swift */; }; + BB2F792D24A3F905000567C9 /* Expo.plist in Resources */ = {isa = PBXBuildFile; fileRef = BB2F792C24A3F905000567C9 /* Expo.plist */; }; + E112987CA504453081AC7CD8 /* noop-file.swift in Sources */ = {isa = PBXBuildFile; fileRef = C911AA20BDD841449D1CE041 /* noop-file.swift */; }; +/* End PBXBuildFile section */ + +/* Begin PBXFileReference section */ + 008F07F21AC5B25A0029DE68 /* main.jsbundle */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text; path = main.jsbundle; sourceTree = ""; }; + 13B07F961A680F5B00A75B9A /* testapp.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = testapp.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 13B07FAF1A68108700A75B9A /* AppDelegate.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; name = AppDelegate.h; path = testapp/AppDelegate.h; sourceTree = ""; }; + 13B07FB01A68108700A75B9A /* AppDelegate.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; name = AppDelegate.m; path = testapp/AppDelegate.m; sourceTree = ""; }; + 13B07FB51A68108700A75B9A /* Images.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; name = Images.xcassets; path = testapp/Images.xcassets; sourceTree = ""; }; + 13B07FB61A68108700A75B9A /* Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = Info.plist; path = testapp/Info.plist; sourceTree = ""; }; + 13B07FB71A68108700A75B9A /* main.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; name = main.m; path = testapp/main.m; sourceTree = ""; }; + 58EEBF8E8E6FB1BC6CAF49B5 /* libPods-testapp.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = "libPods-testapp.a"; sourceTree = BUILT_PRODUCTS_DIR; }; + 6C2E3173556A471DD304B334 /* Pods-testapp.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-testapp.debug.xcconfig"; path = "Target Support Files/Pods-testapp/Pods-testapp.debug.xcconfig"; sourceTree = ""; }; + 7A4D352CD337FB3A3BF06240 /* Pods-testapp.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-testapp.release.xcconfig"; path = "Target Support Files/Pods-testapp/Pods-testapp.release.xcconfig"; sourceTree = ""; }; + AA286B85B6C04FC6940260E9 /* SplashScreen.storyboard */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.storyboard; name = SplashScreen.storyboard; path = testapp/SplashScreen.storyboard; sourceTree = ""; }; + BB2F792C24A3F905000567C9 /* Expo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = Expo.plist; sourceTree = ""; }; + ED297162215061F000B7C4FE /* JavaScriptCore.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = JavaScriptCore.framework; path = System/Library/Frameworks/JavaScriptCore.framework; sourceTree = SDKROOT; }; + FAC715A2D49A985799AEE119 /* ExpoModulesProvider.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = ExpoModulesProvider.swift; path = "Pods/Target Support Files/Pods-testapp/ExpoModulesProvider.swift"; sourceTree = ""; }; + C911AA20BDD841449D1CE041 /* noop-file.swift */ = {isa = PBXFileReference; name = "noop-file.swift"; path = "testapp/noop-file.swift"; sourceTree = ""; fileEncoding = 4; lastKnownFileType = sourcecode.swift; explicitFileType = undefined; includeInIndex = 0; }; + 0962172184234A4793928022 /* testapp-Bridging-Header.h */ = {isa = PBXFileReference; name = "testapp-Bridging-Header.h"; path = "testapp/testapp-Bridging-Header.h"; sourceTree = ""; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; explicitFileType = undefined; includeInIndex = 0; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + 13B07F8C1A680F5B00A75B9A /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 96905EF65AED1B983A6B3ABC /* libPods-testapp.a in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 13B07FAE1A68108700A75B9A /* testapp */ = { + isa = PBXGroup; + children = ( + BB2F792B24A3F905000567C9 /* Supporting */, + 008F07F21AC5B25A0029DE68 /* main.jsbundle */, + 13B07FAF1A68108700A75B9A /* AppDelegate.h */, + 13B07FB01A68108700A75B9A /* AppDelegate.m */, + 13B07FB51A68108700A75B9A /* Images.xcassets */, + 13B07FB61A68108700A75B9A /* Info.plist */, + 13B07FB71A68108700A75B9A /* main.m */, + AA286B85B6C04FC6940260E9 /* SplashScreen.storyboard */, + C911AA20BDD841449D1CE041 /* noop-file.swift */, + 0962172184234A4793928022 /* testapp-Bridging-Header.h */, + ); + name = testapp; + sourceTree = ""; + }; + 2D16E6871FA4F8E400B85C8A /* Frameworks */ = { + isa = PBXGroup; + children = ( + ED297162215061F000B7C4FE /* JavaScriptCore.framework */, + 58EEBF8E8E6FB1BC6CAF49B5 /* libPods-testapp.a */, + ); + name = Frameworks; + sourceTree = ""; + }; + 832341AE1AAA6A7D00B99B32 /* Libraries */ = { + isa = PBXGroup; + children = ( + ); + name = Libraries; + sourceTree = ""; + }; + 83CBB9F61A601CBA00E9B192 = { + isa = PBXGroup; + children = ( + 13B07FAE1A68108700A75B9A /* testapp */, + 832341AE1AAA6A7D00B99B32 /* Libraries */, + 83CBBA001A601CBA00E9B192 /* Products */, + 2D16E6871FA4F8E400B85C8A /* Frameworks */, + D65327D7A22EEC0BE12398D9 /* Pods */, + D7E4C46ADA2E9064B798F356 /* ExpoModulesProviders */, + ); + indentWidth = 2; + sourceTree = ""; + tabWidth = 2; + usesTabs = 0; + }; + 83CBBA001A601CBA00E9B192 /* Products */ = { + isa = PBXGroup; + children = ( + 13B07F961A680F5B00A75B9A /* testapp.app */, + ); + name = Products; + sourceTree = ""; + }; + 92DBD88DE9BF7D494EA9DA96 /* testapp */ = { + isa = PBXGroup; + children = ( + FAC715A2D49A985799AEE119 /* ExpoModulesProvider.swift */, + ); + name = testapp; + sourceTree = ""; + }; + BB2F792B24A3F905000567C9 /* Supporting */ = { + isa = PBXGroup; + children = ( + BB2F792C24A3F905000567C9 /* Expo.plist */, + ); + name = Supporting; + path = testapp/Supporting; + sourceTree = ""; + }; + D65327D7A22EEC0BE12398D9 /* Pods */ = { + isa = PBXGroup; + children = ( + 6C2E3173556A471DD304B334 /* Pods-testapp.debug.xcconfig */, + 7A4D352CD337FB3A3BF06240 /* Pods-testapp.release.xcconfig */, + ); + path = Pods; + sourceTree = ""; + }; + D7E4C46ADA2E9064B798F356 /* ExpoModulesProviders */ = { + isa = PBXGroup; + children = ( + 92DBD88DE9BF7D494EA9DA96 /* testapp */, + ); + name = ExpoModulesProviders; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 13B07F861A680F5B00A75B9A /* testapp */ = { + isa = PBXNativeTarget; + buildConfigurationList = 13B07F931A680F5B00A75B9A /* Build configuration list for PBXNativeTarget "testapp" */; + buildPhases = ( + 08A4A3CD28434E44B6B9DE2E /* [CP] Check Pods Manifest.lock */, + FD10A7F022414F080027D42C /* Start Packager */, + 13B07F871A680F5B00A75B9A /* Sources */, + 13B07F8C1A680F5B00A75B9A /* Frameworks */, + 13B07F8E1A680F5B00A75B9A /* Resources */, + 00DD1BFF1BD5951E006B06BC /* Bundle React Native code and images */, + 800E24972A6A228C8D4807E9 /* [CP] Copy Pods Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = testapp; + productName = testapp; + productReference = 13B07F961A680F5B00A75B9A /* testapp.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 83CBB9F71A601CBA00E9B192 /* Project object */ = { + isa = PBXProject; + attributes = { + LastUpgradeCheck = 1130; + TargetAttributes = { + 13B07F861A680F5B00A75B9A = { + LastSwiftMigration = 1250; + DevelopmentTeam = "ABCDEFGH"; + ProvisioningStyle = Manual; + }; + }; + }; + buildConfigurationList = 83CBB9FA1A601CBA00E9B192 /* Build configuration list for PBXProject "testapp" */; + compatibilityVersion = "Xcode 3.2"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = 83CBB9F61A601CBA00E9B192; + productRefGroup = 83CBBA001A601CBA00E9B192 /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 13B07F861A680F5B00A75B9A /* testapp */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 13B07F8E1A680F5B00A75B9A /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + BB2F792D24A3F905000567C9 /* Expo.plist in Resources */, + 13B07FBF1A68108700A75B9A /* Images.xcassets in Resources */, + 3E461D99554A48A4959DE609 /* SplashScreen.storyboard in Resources */, + C9A08D1C39C94E5D824DFA3F /* testapp-Bridging-Header.h in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXShellScriptBuildPhase section */ + 00DD1BFF1BD5951E006B06BC /* Bundle React Native code and images */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + ); + name = "Bundle React Native code and images"; + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "export NODE_BINARY=node\\n\\n# The project root by default is one level up from the ios directory\\nexport PROJECT_ROOT=\\"$PROJECT_DIR\\"/..\\n\\n\`node --print \\"require('path').dirname(require.resolve('react-native/package.json')) + '/scripts/react-native-xcode.sh'\\"\`\\n"; + }; + 08A4A3CD28434E44B6B9DE2E /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "\${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "\${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-testapp-checkManifestLockResult.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "diff \\"\${PODS_PODFILE_DIR_PATH}/Podfile.lock\\" \\"\${PODS_ROOT}/Manifest.lock\\" > /dev/null\\nif [ $? != 0 ] ; then\\n # print error to STDERR\\n echo \\"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\\" >&2\\n exit 1\\nfi\\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\\necho \\"SUCCESS\\" > \\"\${SCRIPT_OUTPUT_FILE_0}\\"\\n"; + showEnvVarsInLog = 0; + }; + 800E24972A6A228C8D4807E9 /* [CP] Copy Pods Resources */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + "\${PODS_ROOT}/Target Support Files/Pods-testapp/Pods-testapp-resources.sh", + "\${PODS_CONFIGURATION_BUILD_DIR}/EXConstants/EXConstants.bundle", + "\${PODS_CONFIGURATION_BUILD_DIR}/EXUpdates/EXUpdates.bundle", + "\${PODS_CONFIGURATION_BUILD_DIR}/React-Core/AccessibilityResources.bundle", + ); + name = "[CP] Copy Pods Resources"; + outputPaths = ( + "\${TARGET_BUILD_DIR}/\${UNLOCALIZED_RESOURCES_FOLDER_PATH}/EXConstants.bundle", + "\${TARGET_BUILD_DIR}/\${UNLOCALIZED_RESOURCES_FOLDER_PATH}/EXUpdates.bundle", + "\${TARGET_BUILD_DIR}/\${UNLOCALIZED_RESOURCES_FOLDER_PATH}/AccessibilityResources.bundle", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\\"\${PODS_ROOT}/Target Support Files/Pods-testapp/Pods-testapp-resources.sh\\"\\n"; + showEnvVarsInLog = 0; + }; + FD10A7F022414F080027D42C /* Start Packager */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + ); + name = "Start Packager"; + outputFileListPaths = ( + ); + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "export RCT_METRO_PORT=\\"\${RCT_METRO_PORT:=8081}\\"\\necho \\"export RCT_METRO_PORT=\${RCT_METRO_PORT}\\" > \`node --print \\"require('path').dirname(require.resolve('react-native/package.json')) + '/scripts/.packager.env'\\"\`\\nif [ -z \\"\${RCT_NO_LAUNCH_PACKAGER+xxx}\\" ] ; then\\n if nc -w 5 -z localhost \${RCT_METRO_PORT} ; then\\n if ! curl -s \\"http://localhost:\${RCT_METRO_PORT}/status\\" | grep -q \\"packager-status:running\\" ; then\\n echo \\"Port \${RCT_METRO_PORT} already in use, packager is either not running or not running correctly\\"\\n exit 2\\n fi\\n else\\n open \`node --print \\"require('path').dirname(require.resolve('react-native/package.json')) + '/scripts/launchPackager.command'\\"\` || echo \\"Can't start packager automatically\\"\\n fi\\nfi\\n"; + showEnvVarsInLog = 0; + }; +/* End PBXShellScriptBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 13B07F871A680F5B00A75B9A /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 13B07FBC1A68108700A75B9A /* AppDelegate.m in Sources */, + 13B07FC11A68108700A75B9A /* main.m in Sources */, + B18059E884C0ABDD17F3DC3D /* ExpoModulesProvider.swift in Sources */, + E112987CA504453081AC7CD8 /* noop-file.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin XCBuildConfiguration section */ + 13B07F941A680F5B00A75B9A /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 6C2E3173556A471DD304B334 /* Pods-testapp.debug.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CURRENT_PROJECT_VERSION = 1; + ENABLE_BITCODE = NO; + GCC_PREPROCESSOR_DEFINITIONS = ( + "$(inherited)", + "FB_SONARKIT_ENABLED=1", + ); + INFOPLIST_FILE = testapp/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 12.0; + LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; + OTHER_LDFLAGS = ( + "$(inherited)", + "-ObjC", + "-lc++", + ); + PRODUCT_BUNDLE_IDENTIFIER = "org.reactjs.native.example.testapp.turtlev2"; + PRODUCT_NAME = turtlev2; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + TARGETED_DEVICE_FAMILY = "1,2"; + SWIFT_OBJC_BRIDGING_HEADER = testapp/testapp-Bridging-Header.h; + CODE_SIGN_ENTITLEMENTS = testapp/turtlev2.entitlements; + }; + name = Debug; + }; + 13B07F951A680F5B00A75B9A /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7A4D352CD337FB3A3BF06240 /* Pods-testapp.release.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CURRENT_PROJECT_VERSION = 1; + INFOPLIST_FILE = testapp/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 12.0; + LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; + OTHER_LDFLAGS = ( + "$(inherited)", + "-ObjC", + "-lc++", + ); + PRODUCT_BUNDLE_IDENTIFIER = "org.reactjs.native.example.testapp.turtlev2"; + PRODUCT_NAME = turtlev2; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + TARGETED_DEVICE_FAMILY = "1,2"; + SWIFT_OBJC_BRIDGING_HEADER = testapp/testapp-Bridging-Header.h; + CODE_SIGN_ENTITLEMENTS = testapp/turtlev2.entitlements; + PROVISIONING_PROFILE_SPECIFIER = "profile name"; + DEVELOPMENT_TEAM = "ABCDEFGH"; + CODE_SIGN_IDENTITY = "iPhone Distribution"; + CODE_SIGN_STYLE = Manual; + }; + name = Release; + }; + 83CBBA201A601CBA00E9B192 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_SYMBOLS_PRIVATE_EXTERN = NO; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 12.0; + LD_RUNPATH_SEARCH_PATHS = "/usr/lib/swift $(inherited)"; + LIBRARY_SEARCH_PATHS = "\\"$(inherited)\\""; + MTL_ENABLE_DEBUG_INFO = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = iphoneos; + CODE_SIGN_ENTITLEMENTS = testapp/turtlev2.entitlements; + }; + name = Debug; + }; + 83CBBA211A601CBA00E9B192 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = YES; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 12.0; + LD_RUNPATH_SEARCH_PATHS = "/usr/lib/swift $(inherited)"; + LIBRARY_SEARCH_PATHS = "\\"$(inherited)\\""; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = iphoneos; + VALIDATE_PRODUCT = YES; + CODE_SIGN_ENTITLEMENTS = testapp/turtlev2.entitlements; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 13B07F931A680F5B00A75B9A /* Build configuration list for PBXNativeTarget "testapp" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 13B07F941A680F5B00A75B9A /* Debug */, + 13B07F951A680F5B00A75B9A /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 83CBB9FA1A601CBA00E9B192 /* Build configuration list for PBXProject "testapp" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 83CBBA201A601CBA00E9B192 /* Debug */, + 83CBBA211A601CBA00E9B192 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + }; + rootObject = 83CBB9F71A601CBA00E9B192 /* Project object */; +} +" +`; + +exports[`updateVersionsAsync configures versions for a simple project: Info.plist application target 1`] = ` +" + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + testapp + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + $(PRODUCT_BUNDLE_PACKAGE_TYPE) + CFBundleShortVersionString + 1.2.3 + CFBundleSignature + ???? + CFBundleURLTypes + + + CFBundleURLSchemes + + org.reactjs.native.example.testapp.turtlev2 + + + + CFBundleVersion + 1.2.4 + LSRequiresIPhoneOS + + NSAppTransportSecurity + + NSAllowsArbitraryLoads + + NSExceptionDomains + + localhost + + NSExceptionAllowsInsecureHTTPLoads + + + + + UILaunchStoryboardName + SplashScreen + UIRequiredDeviceCapabilities + + armv7 + + UIRequiresFullScreen + + UIStatusBarStyle + UIStatusBarStyleDefault + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + + UISupportedInterfaceOrientations~ipad + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UIUserInterfaceStyle + Light + UIViewControllerBasedStatusBarAppearance + + +" +`; diff --git a/packages/build-tools/src/steps/utils/ios/__tests__/configure.test.ts b/packages/build-tools/src/steps/utils/ios/__tests__/configure.test.ts new file mode 100644 index 0000000000..e57ae7f038 --- /dev/null +++ b/packages/build-tools/src/steps/utils/ios/__tests__/configure.test.ts @@ -0,0 +1,228 @@ +import path from 'path'; + +import { vol } from 'memfs'; + +import { configureCredentialsAsync, updateVersionsAsync } from '../configure'; +import ProvisioningProfile, { DistributionType } from '../credentials/provisioningProfile'; + +jest.mock('fs'); +const originalFs = jest.requireActual('fs'); + +afterEach(() => { + vol.reset(); +}); + +describe(configureCredentialsAsync, () => { + it('configures credentials for a simple project', async () => { + vol.fromJSON( + { + 'ios/testapp.xcodeproj/project.pbxproj': originalFs.readFileSync( + path.join(__dirname, 'fixtures/simple-project.pbxproj'), + 'utf-8' + ), + 'ios/testapp/AppDelegate.m': 'placeholder', + }, + '/app' + ); + const options = { + credentials: { + keychainPath: 'fake/path', + targetProvisioningProfiles: { + testapp: { + path: 'fake/path.mobileprovision', + target: 'testapp', + bundleIdentifier: 'abc', + teamId: 'ABCDEFGH', + uuid: 'abc', + name: 'profile name', + developerCertificate: Buffer.from('test'), + certificateCommonName: 'Abc 123', + distributionType: DistributionType.APP_STORE, + }, + }, + distributionType: DistributionType.APP_STORE, + teamId: 'ABCDEFGH', + applicationTargetProvisioningProfile: {} as ProvisioningProfile, + }, + buildConfiguration: 'Release', + }; + await configureCredentialsAsync({ info: jest.fn() } as any, '/app', options); + expect( + vol.readFileSync('/app/ios/testapp.xcodeproj/project.pbxproj', 'utf-8') + ).toMatchSnapshot(); + }); + it('configures credentials for multi target project', async () => { + vol.fromJSON( + { + 'ios/testapp.xcodeproj/project.pbxproj': originalFs.readFileSync( + path.join(__dirname, 'fixtures/multitarget-project.pbxproj'), + 'utf-8' + ), + 'ios/testapp/AppDelegate.m': 'placeholder', + }, + '/app' + ); + const options = { + credentials: { + keychainPath: 'fake/path', + targetProvisioningProfiles: { + shareextension: { + path: 'fake/path1.mobileprovision', + target: 'shareextension', + bundleIdentifier: 'abc.extension', + teamId: 'ABCDEFGH', + uuid: 'abc', + name: 'extension profile', + developerCertificate: Buffer.from('test'), + certificateCommonName: 'Abc 123', + distributionType: DistributionType.APP_STORE, + }, + multitarget: { + path: 'fake/path2.mobileprovision', + target: 'multitarget', + bundleIdentifier: 'abc', + teamId: 'ABCDEFGH', + uuid: 'abc', + name: 'multitarget profile', + developerCertificate: Buffer.from('test'), + certificateCommonName: 'Abc 123', + distributionType: DistributionType.APP_STORE, + }, + }, + distributionType: DistributionType.APP_STORE, + teamId: 'ABCDEFGH', + applicationTargetProvisioningProfile: {} as ProvisioningProfile, + }, + buildConfiguration: 'Release', + }; + await configureCredentialsAsync({ info: jest.fn() } as any, '/app', options); + expect( + vol.readFileSync('/app/ios/testapp.xcodeproj/project.pbxproj', 'utf-8') + ).toMatchSnapshot(); + }); +}); + +describe(updateVersionsAsync, () => { + it('configures versions for a simple project', async () => { + vol.fromJSON( + { + 'ios/testapp/Info.plist': originalFs.readFileSync( + path.join(__dirname, 'fixtures/Info.plist'), + 'utf-8' + ), + 'ios/testapp.xcodeproj/project.pbxproj': originalFs.readFileSync( + path.join(__dirname, 'fixtures/simple-project.pbxproj'), + 'utf-8' + ), + 'ios/testapp/AppDelegate.m': 'placeholder', + }, + '/app' + ); + const options = { + credentials: { + keychainPath: 'fake/path', + targetProvisioningProfiles: { + testapp: { + path: 'fake/path.mobileprovision', + target: 'testapp', + bundleIdentifier: 'abc', + teamId: 'ABCDEFGH', + uuid: 'abc', + name: 'profile name', + developerCertificate: Buffer.from('test'), + certificateCommonName: 'Abc 123', + distributionType: DistributionType.APP_STORE, + }, + }, + distributionType: DistributionType.APP_STORE, + teamId: 'ABCDEFGH', + applicationTargetProvisioningProfile: {} as ProvisioningProfile, + }, + buildConfiguration: 'Release', + }; + + await configureCredentialsAsync({ info: jest.fn() } as any, '/app', options); + await updateVersionsAsync( + { info: jest.fn() } as any, + '/app', + { appVersion: '1.2.3', buildNumber: '1.2.4' }, + { ...options, targetNames: Object.keys(options.credentials.targetProvisioningProfiles) } + ); + expect( + vol.readFileSync('/app/ios/testapp.xcodeproj/project.pbxproj', 'utf-8') + ).toMatchSnapshot(); + expect(vol.readFileSync('/app/ios/testapp/Info.plist', 'utf-8')).toMatchSnapshot( + 'Info.plist application target' + ); + }); + it('configures credentials and versions for multi target project', async () => { + vol.fromJSON( + { + 'ios/multitarget/Info.plist': originalFs.readFileSync( + path.join(__dirname, 'fixtures/Info.plist'), + 'utf-8' + ), + 'ios/shareextension/Info.plist': originalFs.readFileSync( + path.join(__dirname, 'fixtures/Info.plist'), + 'utf-8' + ), + 'ios/testapp.xcodeproj/project.pbxproj': originalFs.readFileSync( + path.join(__dirname, 'fixtures/multitarget-project.pbxproj'), + 'utf-8' + ), + 'ios/testapp/AppDelegate.m': 'placeholder', + }, + '/app' + ); + const options = { + credentials: { + keychainPath: 'fake/path', + targetProvisioningProfiles: { + shareextension: { + path: 'fake/path1.mobileprovision', + target: 'shareextension', + bundleIdentifier: 'abc.extension', + teamId: 'ABCDEFGH', + uuid: 'abc', + name: 'extension profile', + developerCertificate: Buffer.from('test'), + certificateCommonName: 'Abc 123', + distributionType: DistributionType.APP_STORE, + }, + multitarget: { + path: 'fake/path2.mobileprovision', + target: 'multitarget', + bundleIdentifier: 'abc', + teamId: 'ABCDEFGH', + uuid: 'abc', + name: 'multitarget profile', + developerCertificate: Buffer.from('test'), + certificateCommonName: 'Abc 123', + distributionType: DistributionType.APP_STORE, + }, + }, + distributionType: DistributionType.APP_STORE, + teamId: 'ABCDEFGH', + applicationTargetProvisioningProfile: {} as ProvisioningProfile, + }, + buildConfiguration: 'Release', + }; + + await configureCredentialsAsync({ info: jest.fn() } as any, '/app', options); + await updateVersionsAsync( + { info: jest.fn() } as any, + '/app', + { appVersion: '1.2.3', buildNumber: '1.2.4' }, + { ...options, targetNames: Object.keys(options.credentials.targetProvisioningProfiles) } + ); + expect( + vol.readFileSync('/app/ios/testapp.xcodeproj/project.pbxproj', 'utf-8') + ).toMatchSnapshot(); + expect(vol.readFileSync('/app/ios/shareextension/Info.plist', 'utf-8')).toMatchSnapshot( + 'Info.plist application target' + ); + expect(vol.readFileSync('/app/ios/multitarget/Info.plist', 'utf-8')).toMatchSnapshot( + 'Info.plist extension' + ); + }); +}); diff --git a/packages/build-tools/src/steps/utils/ios/__tests__/expoUpdates.test.ts b/packages/build-tools/src/steps/utils/ios/__tests__/expoUpdates.test.ts new file mode 100644 index 0000000000..a9bb574267 --- /dev/null +++ b/packages/build-tools/src/steps/utils/ios/__tests__/expoUpdates.test.ts @@ -0,0 +1,118 @@ +import fs from 'fs-extra'; +import plist from '@expo/plist'; +import { vol } from 'memfs'; + +import { + IosMetadataName, + iosSetChannelNativelyAsync, + iosGetNativelyDefinedChannelAsync, + iosSetRuntimeVersionNativelyAsync, +} from '../../ios/expoUpdates'; + +jest.mock('fs'); + +const expoPlistPath = '/app/ios/testapp/Supporting/Expo.plist'; +const noItemsExpoPlist = ` + + + + + +`; +const channel = 'easupdatechannel'; + +afterEach(() => { + vol.reset(); +}); + +describe(iosSetChannelNativelyAsync, () => { + it('sets the channel', async () => { + vol.fromJSON( + { + 'ios/testapp/Supporting/Expo.plist': noItemsExpoPlist, + 'ios/testapp.xcodeproj/project.pbxproj': 'placeholder', + 'ios/testapp/AppDelegate.m': 'placeholder', + }, + '/app' + ); + + await iosSetChannelNativelyAsync(channel, '/app'); + + const newExpoPlist = await fs.readFile(expoPlistPath, 'utf8'); + expect( + plist.parse(newExpoPlist)[IosMetadataName.UPDATES_CONFIGURATION_REQUEST_HEADERS_KEY] + ).toEqual({ 'expo-channel-name': channel }); + }); +}); + +describe(iosGetNativelyDefinedChannelAsync, () => { + it('gets the channel', async () => { + const expoPlist = ` + + + + + EXUpdatesRequestHeaders + + expo-channel-name + staging-123 + + + + `; + + vol.fromJSON( + { + 'ios/testapp/Supporting/Expo.plist': expoPlist, + 'ios/testapp.xcodeproj/project.pbxproj': 'placeholder', + 'ios/testapp/AppDelegate.m': 'placeholder', + }, + '/app' + ); + + await expect(iosGetNativelyDefinedChannelAsync('/app')).resolves.toBe('staging-123'); + }); +}); + +describe(iosSetRuntimeVersionNativelyAsync, () => { + it("sets runtime version if it's not specified", async () => { + vol.fromJSON( + { + 'ios/testapp/Supporting/Expo.plist': noItemsExpoPlist, + 'ios/testapp.xcodeproj/project.pbxproj': 'placeholder', + 'ios/testapp/AppDelegate.m': 'placeholder', + }, + '/app' + ); + + await iosSetRuntimeVersionNativelyAsync('1.2.3', '/app'); + + const newExpoPlist = await fs.readFile(expoPlistPath, 'utf8'); + expect(plist.parse(newExpoPlist)[IosMetadataName.RUNTIME_VERSION]).toEqual('1.2.3'); + }); + it("updates runtime version if it's already defined", async () => { + const expoPlist = ` + + + + + RELEASE_CHANNEL + examplereleasechannel + + `; + + vol.fromJSON( + { + 'ios/testapp/Supporting/Expo.plist': expoPlist, + 'ios/testapp.xcodeproj/project.pbxproj': 'placeholder', + 'ios/testapp/AppDelegate.m': 'placeholder', + }, + '/app' + ); + + await iosSetRuntimeVersionNativelyAsync('1.2.3', '/app'); + + const newExpoPlist = await fs.readFile(expoPlistPath, 'utf8'); + expect(plist.parse(newExpoPlist)[IosMetadataName.RUNTIME_VERSION]).toEqual('1.2.3'); + }); +}); diff --git a/packages/build-tools/src/steps/utils/ios/__tests__/fixtures/Info.plist b/packages/build-tools/src/steps/utils/ios/__tests__/fixtures/Info.plist new file mode 100644 index 0000000000..94b0865318 --- /dev/null +++ b/packages/build-tools/src/steps/utils/ios/__tests__/fixtures/Info.plist @@ -0,0 +1,76 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + testapp + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + $(PRODUCT_BUNDLE_PACKAGE_TYPE) + CFBundleShortVersionString + 1.0.0 + CFBundleSignature + ???? + CFBundleURLTypes + + + CFBundleURLSchemes + + org.reactjs.native.example.testapp.turtlev2 + + + + CFBundleVersion + 1 + LSRequiresIPhoneOS + + NSAppTransportSecurity + + NSAllowsArbitraryLoads + + NSExceptionDomains + + localhost + + NSExceptionAllowsInsecureHTTPLoads + + + + + UILaunchStoryboardName + SplashScreen + UIRequiredDeviceCapabilities + + armv7 + + UIRequiresFullScreen + + UIStatusBarStyle + UIStatusBarStyleDefault + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + + UISupportedInterfaceOrientations~ipad + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UIUserInterfaceStyle + Light + UIViewControllerBasedStatusBarAppearance + + + diff --git a/packages/build-tools/src/steps/utils/ios/__tests__/fixtures/multitarget-project.pbxproj b/packages/build-tools/src/steps/utils/ios/__tests__/fixtures/multitarget-project.pbxproj new file mode 100644 index 0000000000..2056ba080a --- /dev/null +++ b/packages/build-tools/src/steps/utils/ios/__tests__/fixtures/multitarget-project.pbxproj @@ -0,0 +1,655 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 46; + objects = { + +/* Begin PBXBuildFile section */ + 13B07FBC1A68108700A75B9A /* AppDelegate.m in Sources */ = {isa = PBXBuildFile; fileRef = 13B07FB01A68108700A75B9A /* AppDelegate.m */; }; + 13B07FBD1A68108700A75B9A /* LaunchScreen.xib in Resources */ = {isa = PBXBuildFile; fileRef = 13B07FB11A68108700A75B9A /* LaunchScreen.xib */; }; + 13B07FBF1A68108700A75B9A /* Images.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 13B07FB51A68108700A75B9A /* Images.xcassets */; }; + 13B07FC11A68108700A75B9A /* main.m in Sources */ = {isa = PBXBuildFile; fileRef = 13B07FB71A68108700A75B9A /* main.m */; }; + 3E461D99554A48A4959DE609 /* SplashScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = AA286B85B6C04FC6940260E9 /* SplashScreen.storyboard */; }; + 6ADAD6E626493A420001F56E /* ShareViewController.m in Sources */ = {isa = PBXBuildFile; fileRef = 6ADAD6E526493A420001F56E /* ShareViewController.m */; }; + 6ADAD6E926493A420001F56E /* MainInterface.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 6ADAD6E726493A420001F56E /* MainInterface.storyboard */; }; + 6ADAD6ED26493A420001F56E /* shareextension.appex in Embed App Extensions */ = {isa = PBXBuildFile; fileRef = 6ADAD6E226493A420001F56E /* shareextension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; + 96905EF65AED1B983A6B3ABC /* libPods-multitarget.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 58EEBF8E8E6FB1BC6CAF49B5 /* libPods-multitarget.a */; }; + BB2F792D24A3F905000567C9 /* Expo.plist in Resources */ = {isa = PBXBuildFile; fileRef = BB2F792C24A3F905000567C9 /* Expo.plist */; }; +/* End PBXBuildFile section */ + +/* Begin PBXContainerItemProxy section */ + 6ADAD6EB26493A420001F56E /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 83CBB9F71A601CBA00E9B192 /* Project object */; + proxyType = 1; + remoteGlobalIDString = 6ADAD6E126493A420001F56E; + remoteInfo = shareextension; + }; +/* End PBXContainerItemProxy section */ + +/* Begin PBXCopyFilesBuildPhase section */ + 6ADAD6F126493A420001F56E /* Embed App Extensions */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 13; + files = ( + 6ADAD6ED26493A420001F56E /* shareextension.appex in Embed App Extensions */, + ); + name = "Embed App Extensions"; + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXCopyFilesBuildPhase section */ + +/* Begin PBXFileReference section */ + 008F07F21AC5B25A0029DE68 /* main.jsbundle */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text; path = main.jsbundle; sourceTree = ""; }; + 13B07F961A680F5B00A75B9A /* multitarget.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = multitarget.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 13B07FAF1A68108700A75B9A /* AppDelegate.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; name = AppDelegate.h; path = multitarget/AppDelegate.h; sourceTree = ""; }; + 13B07FB01A68108700A75B9A /* AppDelegate.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; name = AppDelegate.m; path = multitarget/AppDelegate.m; sourceTree = ""; }; + 13B07FB21A68108700A75B9A /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.xib; name = Base; path = Base.lproj/LaunchScreen.xib; sourceTree = ""; }; + 13B07FB51A68108700A75B9A /* Images.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; name = Images.xcassets; path = multitarget/Images.xcassets; sourceTree = ""; }; + 13B07FB61A68108700A75B9A /* Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = Info.plist; path = multitarget/Info.plist; sourceTree = ""; }; + 13B07FB71A68108700A75B9A /* main.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; name = main.m; path = multitarget/main.m; sourceTree = ""; }; + 58EEBF8E8E6FB1BC6CAF49B5 /* libPods-multitarget.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = "libPods-multitarget.a"; sourceTree = BUILT_PRODUCTS_DIR; }; + 6ADAD6E226493A420001F56E /* shareextension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = shareextension.appex; sourceTree = BUILT_PRODUCTS_DIR; }; + 6ADAD6E426493A420001F56E /* ShareViewController.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = ShareViewController.h; sourceTree = ""; }; + 6ADAD6E526493A420001F56E /* ShareViewController.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = ShareViewController.m; sourceTree = ""; }; + 6ADAD6E826493A420001F56E /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/MainInterface.storyboard; sourceTree = ""; }; + 6ADAD6EA26493A420001F56E /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + 6C2E3173556A471DD304B334 /* Pods-multitarget.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-multitarget.debug.xcconfig"; path = "Target Support Files/Pods-multitarget/Pods-multitarget.debug.xcconfig"; sourceTree = ""; }; + 7A4D352CD337FB3A3BF06240 /* Pods-multitarget.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-multitarget.release.xcconfig"; path = "Target Support Files/Pods-multitarget/Pods-multitarget.release.xcconfig"; sourceTree = ""; }; + AA286B85B6C04FC6940260E9 /* SplashScreen.storyboard */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.storyboard; name = SplashScreen.storyboard; path = multitarget/SplashScreen.storyboard; sourceTree = ""; }; + BB2F792C24A3F905000567C9 /* Expo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = Expo.plist; sourceTree = ""; }; + ED297162215061F000B7C4FE /* JavaScriptCore.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = JavaScriptCore.framework; path = System/Library/Frameworks/JavaScriptCore.framework; sourceTree = SDKROOT; }; + ED2971642150620600B7C4FE /* JavaScriptCore.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = JavaScriptCore.framework; path = Platforms/AppleTVOS.platform/Developer/SDKs/AppleTVOS12.0.sdk/System/Library/Frameworks/JavaScriptCore.framework; sourceTree = DEVELOPER_DIR; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + 13B07F8C1A680F5B00A75B9A /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 96905EF65AED1B983A6B3ABC /* libPods-multitarget.a in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 6ADAD6DF26493A420001F56E /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 13B07FAE1A68108700A75B9A /* multitarget */ = { + isa = PBXGroup; + children = ( + BB2F792B24A3F905000567C9 /* Supporting */, + 008F07F21AC5B25A0029DE68 /* main.jsbundle */, + 13B07FAF1A68108700A75B9A /* AppDelegate.h */, + 13B07FB01A68108700A75B9A /* AppDelegate.m */, + 13B07FB51A68108700A75B9A /* Images.xcassets */, + 13B07FB61A68108700A75B9A /* Info.plist */, + 13B07FB11A68108700A75B9A /* LaunchScreen.xib */, + 13B07FB71A68108700A75B9A /* main.m */, + AA286B85B6C04FC6940260E9 /* SplashScreen.storyboard */, + ); + name = multitarget; + sourceTree = ""; + }; + 2D16E6871FA4F8E400B85C8A /* Frameworks */ = { + isa = PBXGroup; + children = ( + ED297162215061F000B7C4FE /* JavaScriptCore.framework */, + ED2971642150620600B7C4FE /* JavaScriptCore.framework */, + 58EEBF8E8E6FB1BC6CAF49B5 /* libPods-multitarget.a */, + ); + name = Frameworks; + sourceTree = ""; + }; + 6ADAD6E326493A420001F56E /* shareextension */ = { + isa = PBXGroup; + children = ( + 6ADAD6E426493A420001F56E /* ShareViewController.h */, + 6ADAD6E526493A420001F56E /* ShareViewController.m */, + 6ADAD6E726493A420001F56E /* MainInterface.storyboard */, + 6ADAD6EA26493A420001F56E /* Info.plist */, + ); + path = shareextension; + sourceTree = ""; + }; + 832341AE1AAA6A7D00B99B32 /* Libraries */ = { + isa = PBXGroup; + children = ( + ); + name = Libraries; + sourceTree = ""; + }; + 83CBB9F61A601CBA00E9B192 = { + isa = PBXGroup; + children = ( + 13B07FAE1A68108700A75B9A /* multitarget */, + 832341AE1AAA6A7D00B99B32 /* Libraries */, + 6ADAD6E326493A420001F56E /* shareextension */, + 83CBBA001A601CBA00E9B192 /* Products */, + 2D16E6871FA4F8E400B85C8A /* Frameworks */, + D65327D7A22EEC0BE12398D9 /* Pods */, + ); + indentWidth = 2; + sourceTree = ""; + tabWidth = 2; + usesTabs = 0; + }; + 83CBBA001A601CBA00E9B192 /* Products */ = { + isa = PBXGroup; + children = ( + 13B07F961A680F5B00A75B9A /* multitarget.app */, + 6ADAD6E226493A420001F56E /* shareextension.appex */, + ); + name = Products; + sourceTree = ""; + }; + BB2F792B24A3F905000567C9 /* Supporting */ = { + isa = PBXGroup; + children = ( + BB2F792C24A3F905000567C9 /* Expo.plist */, + ); + name = Supporting; + path = multitarget/Supporting; + sourceTree = ""; + }; + D65327D7A22EEC0BE12398D9 /* Pods */ = { + isa = PBXGroup; + children = ( + 6C2E3173556A471DD304B334 /* Pods-multitarget.debug.xcconfig */, + 7A4D352CD337FB3A3BF06240 /* Pods-multitarget.release.xcconfig */, + ); + path = Pods; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 13B07F861A680F5B00A75B9A /* multitarget */ = { + isa = PBXNativeTarget; + buildConfigurationList = 13B07F931A680F5B00A75B9A /* Build configuration list for PBXNativeTarget "multitarget" */; + buildPhases = ( + 08A4A3CD28434E44B6B9DE2E /* [CP] Check Pods Manifest.lock */, + FD10A7F022414F080027D42C /* Start Packager */, + 13B07F871A680F5B00A75B9A /* Sources */, + 13B07F8C1A680F5B00A75B9A /* Frameworks */, + 13B07F8E1A680F5B00A75B9A /* Resources */, + 00DD1BFF1BD5951E006B06BC /* Bundle React Native code and images */, + AF7BF8C823CE838DA4182532 /* [CP] Copy Pods Resources */, + 6ADAD6F126493A420001F56E /* Embed App Extensions */, + ); + buildRules = ( + ); + dependencies = ( + 6ADAD6EC26493A420001F56E /* PBXTargetDependency */, + ); + name = multitarget; + productName = multitarget; + productReference = 13B07F961A680F5B00A75B9A /* multitarget.app */; + productType = "com.apple.product-type.application"; + }; + 6ADAD6E126493A420001F56E /* shareextension */ = { + isa = PBXNativeTarget; + buildConfigurationList = 6ADAD6EE26493A420001F56E /* Build configuration list for PBXNativeTarget "shareextension" */; + buildPhases = ( + 6ADAD6DE26493A420001F56E /* Sources */, + 6ADAD6DF26493A420001F56E /* Frameworks */, + 6ADAD6E026493A420001F56E /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = shareextension; + productName = shareextension; + productReference = 6ADAD6E226493A420001F56E /* shareextension.appex */; + productType = "com.apple.product-type.app-extension"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 83CBB9F71A601CBA00E9B192 /* Project object */ = { + isa = PBXProject; + attributes = { + LastUpgradeCheck = 1130; + TargetAttributes = { + 13B07F861A680F5B00A75B9A = { + DevelopmentTeam = QL76XYH73P; + LastSwiftMigration = 1120; + }; + 6ADAD6E126493A420001F56E = { + CreatedOnToolsVersion = 12.5; + DevelopmentTeam = QL76XYH73P; + ProvisioningStyle = Automatic; + }; + }; + }; + buildConfigurationList = 83CBB9FA1A601CBA00E9B192 /* Build configuration list for PBXProject "multitarget" */; + compatibilityVersion = "Xcode 3.2"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = 83CBB9F61A601CBA00E9B192; + productRefGroup = 83CBBA001A601CBA00E9B192 /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 13B07F861A680F5B00A75B9A /* multitarget */, + 6ADAD6E126493A420001F56E /* shareextension */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 13B07F8E1A680F5B00A75B9A /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + BB2F792D24A3F905000567C9 /* Expo.plist in Resources */, + 13B07FBF1A68108700A75B9A /* Images.xcassets in Resources */, + 13B07FBD1A68108700A75B9A /* LaunchScreen.xib in Resources */, + 3E461D99554A48A4959DE609 /* SplashScreen.storyboard in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 6ADAD6E026493A420001F56E /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 6ADAD6E926493A420001F56E /* MainInterface.storyboard in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXShellScriptBuildPhase section */ + 00DD1BFF1BD5951E006B06BC /* Bundle React Native code and images */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + ); + name = "Bundle React Native code and images"; + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "if [[ -f /Users/dsokal/.volta/bin/node ]]; then\n export NODE_BINARY=/Users/dsokal/.volta/bin/node\nelse\n export NODE_BINARY=node\nfi\n../node_modules/react-native/scripts/react-native-xcode.sh\n../node_modules/expo-constants/scripts/get-app-config-ios.sh\n../node_modules/expo-updates/scripts/create-manifest-ios.sh\n"; + }; + 08A4A3CD28434E44B6B9DE2E /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-multitarget-checkManifestLockResult.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; + }; + AF7BF8C823CE838DA4182532 /* [CP] Copy Pods Resources */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-multitarget/Pods-multitarget-resources.sh", + "${PODS_CONFIGURATION_BUILD_DIR}/React-Core/AccessibilityResources.bundle", + ); + name = "[CP] Copy Pods Resources"; + outputPaths = ( + "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/AccessibilityResources.bundle", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-multitarget/Pods-multitarget-resources.sh\"\n"; + showEnvVarsInLog = 0; + }; + FD10A7F022414F080027D42C /* Start Packager */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + ); + name = "Start Packager"; + outputFileListPaths = ( + ); + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "export RCT_METRO_PORT=\"${RCT_METRO_PORT:=8081}\"\necho \"export RCT_METRO_PORT=${RCT_METRO_PORT}\" > \"${SRCROOT}/../node_modules/react-native/scripts/.packager.env\"\nif [ -z \"${RCT_NO_LAUNCH_PACKAGER+xxx}\" ] ; then\n if nc -w 5 -z localhost ${RCT_METRO_PORT} ; then\n if ! curl -s \"http://localhost:${RCT_METRO_PORT}/status\" | grep -q \"packager-status:running\" ; then\n echo \"Port ${RCT_METRO_PORT} already in use, packager is either not running or not running correctly\"\n exit 2\n fi\n else\n open \"$SRCROOT/../node_modules/react-native/scripts/launchPackager.command\" || echo \"Can't start packager automatically\"\n fi\nfi\n"; + showEnvVarsInLog = 0; + }; +/* End PBXShellScriptBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 13B07F871A680F5B00A75B9A /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 13B07FBC1A68108700A75B9A /* AppDelegate.m in Sources */, + 13B07FC11A68108700A75B9A /* main.m in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 6ADAD6DE26493A420001F56E /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 6ADAD6E626493A420001F56E /* ShareViewController.m in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXTargetDependency section */ + 6ADAD6EC26493A420001F56E /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 6ADAD6E126493A420001F56E /* shareextension */; + targetProxy = 6ADAD6EB26493A420001F56E /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + +/* Begin PBXVariantGroup section */ + 13B07FB11A68108700A75B9A /* LaunchScreen.xib */ = { + isa = PBXVariantGroup; + children = ( + 13B07FB21A68108700A75B9A /* Base */, + ); + name = LaunchScreen.xib; + path = multitarget; + sourceTree = ""; + }; + 6ADAD6E726493A420001F56E /* MainInterface.storyboard */ = { + isa = PBXVariantGroup; + children = ( + 6ADAD6E826493A420001F56E /* Base */, + ); + name = MainInterface.storyboard; + sourceTree = ""; + }; +/* End PBXVariantGroup section */ + +/* Begin XCBuildConfiguration section */ + 13B07F941A680F5B00A75B9A /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 6C2E3173556A471DD304B334 /* Pods-multitarget.debug.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = QL76XYH73P; + ENABLE_BITCODE = NO; + GCC_PREPROCESSOR_DEFINITIONS = ( + "$(inherited)", + "FB_SONARKIT_ENABLED=1", + ); + INFOPLIST_FILE = multitarget/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 11.0; + LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; + OTHER_LDFLAGS = ( + "$(inherited)", + "-ObjC", + "-lc++", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.swmansion.dominik.multitarget.debug; + PRODUCT_NAME = multitarget; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = Debug; + }; + 13B07F951A680F5B00A75B9A /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7A4D352CD337FB3A3BF06240 /* Pods-multitarget.release.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_IDENTITY = "Apple Development"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = ""; + INFOPLIST_FILE = multitarget/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 11.0; + LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; + OTHER_LDFLAGS = ( + "$(inherited)", + "-ObjC", + "-lc++", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.swmansion.dominik.multitarget; + PRODUCT_NAME = multitarget; + PROVISIONING_PROFILE_SPECIFIER = ""; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = Release; + }; + 6ADAD6EF26493A420001F56E /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CODE_SIGN_STYLE = Automatic; + DEBUG_INFORMATION_FORMAT = dwarf; + DEVELOPMENT_TEAM = QL76XYH73P; + GCC_C_LANGUAGE_STANDARD = gnu11; + INFOPLIST_FILE = shareextension/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 14.5; + LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @executable_path/../../Frameworks"; + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + MTL_FAST_MATH = YES; + PRODUCT_BUNDLE_IDENTIFIER = com.swmansion.dominik.multitarget.shareextension; + PRODUCT_NAME = "$(TARGET_NAME)"; + SKIP_INSTALL = YES; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + 6ADAD6F026493A420001F56E /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CODE_SIGN_STYLE = Automatic; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + DEVELOPMENT_TEAM = QL76XYH73P; + GCC_C_LANGUAGE_STANDARD = gnu11; + INFOPLIST_FILE = shareextension/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 14.5; + LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @executable_path/../../Frameworks"; + MTL_FAST_MATH = YES; + PRODUCT_BUNDLE_IDENTIFIER = com.swmansion.dominik.multitarget.shareextension; + PRODUCT_NAME = "$(TARGET_NAME)"; + SKIP_INSTALL = YES; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Release; + }; + 83CBBA201A601CBA00E9B192 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_SYMBOLS_PRIVATE_EXTERN = NO; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 11.0; + LD_RUNPATH_SEARCH_PATHS = "/usr/lib/swift $(inherited)"; + LIBRARY_SEARCH_PATHS = ( + "\"$(TOOLCHAIN_DIR)/usr/lib/swift/$(PLATFORM_NAME)\"", + "\"$(TOOLCHAIN_DIR)/usr/lib/swift-5.0/$(PLATFORM_NAME)\"", + "\"$(inherited)\"", + ); + MTL_ENABLE_DEBUG_INFO = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = iphoneos; + }; + name = Debug; + }; + 83CBBA211A601CBA00E9B192 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = YES; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 11.0; + LD_RUNPATH_SEARCH_PATHS = "/usr/lib/swift $(inherited)"; + LIBRARY_SEARCH_PATHS = ( + "\"$(TOOLCHAIN_DIR)/usr/lib/swift/$(PLATFORM_NAME)\"", + "\"$(TOOLCHAIN_DIR)/usr/lib/swift-5.0/$(PLATFORM_NAME)\"", + "\"$(inherited)\"", + ); + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = iphoneos; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 13B07F931A680F5B00A75B9A /* Build configuration list for PBXNativeTarget "multitarget" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 13B07F941A680F5B00A75B9A /* Debug */, + 13B07F951A680F5B00A75B9A /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 6ADAD6EE26493A420001F56E /* Build configuration list for PBXNativeTarget "shareextension" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 6ADAD6EF26493A420001F56E /* Debug */, + 6ADAD6F026493A420001F56E /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 83CBB9FA1A601CBA00E9B192 /* Build configuration list for PBXProject "multitarget" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 83CBBA201A601CBA00E9B192 /* Debug */, + 83CBBA211A601CBA00E9B192 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + }; + rootObject = 83CBB9F71A601CBA00E9B192 /* Project object */; +} diff --git a/packages/build-tools/src/steps/utils/ios/__tests__/fixtures/simple-project.pbxproj b/packages/build-tools/src/steps/utils/ios/__tests__/fixtures/simple-project.pbxproj new file mode 100644 index 0000000000..2e6616e73b --- /dev/null +++ b/packages/build-tools/src/steps/utils/ios/__tests__/fixtures/simple-project.pbxproj @@ -0,0 +1,492 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 46; + objects = { + +/* Begin PBXBuildFile section */ + 13B07FBC1A68108700A75B9A /* AppDelegate.m in Sources */ = {isa = PBXBuildFile; fileRef = 13B07FB01A68108700A75B9A /* AppDelegate.m */; }; + 13B07FBF1A68108700A75B9A /* Images.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 13B07FB51A68108700A75B9A /* Images.xcassets */; }; + 13B07FC11A68108700A75B9A /* main.m in Sources */ = {isa = PBXBuildFile; fileRef = 13B07FB71A68108700A75B9A /* main.m */; }; + 3E461D99554A48A4959DE609 /* SplashScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = AA286B85B6C04FC6940260E9 /* SplashScreen.storyboard */; }; + 96905EF65AED1B983A6B3ABC /* libPods-testapp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 58EEBF8E8E6FB1BC6CAF49B5 /* libPods-testapp.a */; }; + B18059E884C0ABDD17F3DC3D /* ExpoModulesProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = FAC715A2D49A985799AEE119 /* ExpoModulesProvider.swift */; }; + BB2F792D24A3F905000567C9 /* Expo.plist in Resources */ = {isa = PBXBuildFile; fileRef = BB2F792C24A3F905000567C9 /* Expo.plist */; }; + E112987CA504453081AC7CD8 /* noop-file.swift in Sources */ = {isa = PBXBuildFile; fileRef = C911AA20BDD841449D1CE041 /* noop-file.swift */; }; +/* End PBXBuildFile section */ + +/* Begin PBXFileReference section */ + 008F07F21AC5B25A0029DE68 /* main.jsbundle */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text; path = main.jsbundle; sourceTree = ""; }; + 13B07F961A680F5B00A75B9A /* testapp.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = testapp.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 13B07FAF1A68108700A75B9A /* AppDelegate.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; name = AppDelegate.h; path = testapp/AppDelegate.h; sourceTree = ""; }; + 13B07FB01A68108700A75B9A /* AppDelegate.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; name = AppDelegate.m; path = testapp/AppDelegate.m; sourceTree = ""; }; + 13B07FB51A68108700A75B9A /* Images.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; name = Images.xcassets; path = testapp/Images.xcassets; sourceTree = ""; }; + 13B07FB61A68108700A75B9A /* Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = Info.plist; path = testapp/Info.plist; sourceTree = ""; }; + 13B07FB71A68108700A75B9A /* main.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; name = main.m; path = testapp/main.m; sourceTree = ""; }; + 58EEBF8E8E6FB1BC6CAF49B5 /* libPods-testapp.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = "libPods-testapp.a"; sourceTree = BUILT_PRODUCTS_DIR; }; + 6C2E3173556A471DD304B334 /* Pods-testapp.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-testapp.debug.xcconfig"; path = "Target Support Files/Pods-testapp/Pods-testapp.debug.xcconfig"; sourceTree = ""; }; + 7A4D352CD337FB3A3BF06240 /* Pods-testapp.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-testapp.release.xcconfig"; path = "Target Support Files/Pods-testapp/Pods-testapp.release.xcconfig"; sourceTree = ""; }; + AA286B85B6C04FC6940260E9 /* SplashScreen.storyboard */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.storyboard; name = SplashScreen.storyboard; path = testapp/SplashScreen.storyboard; sourceTree = ""; }; + BB2F792C24A3F905000567C9 /* Expo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = Expo.plist; sourceTree = ""; }; + ED297162215061F000B7C4FE /* JavaScriptCore.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = JavaScriptCore.framework; path = System/Library/Frameworks/JavaScriptCore.framework; sourceTree = SDKROOT; }; + FAC715A2D49A985799AEE119 /* ExpoModulesProvider.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = ExpoModulesProvider.swift; path = "Pods/Target Support Files/Pods-testapp/ExpoModulesProvider.swift"; sourceTree = ""; }; + C911AA20BDD841449D1CE041 /* noop-file.swift */ = {isa = PBXFileReference; name = "noop-file.swift"; path = "testapp/noop-file.swift"; sourceTree = ""; fileEncoding = 4; lastKnownFileType = sourcecode.swift; explicitFileType = undefined; includeInIndex = 0; }; + 0962172184234A4793928022 /* testapp-Bridging-Header.h */ = {isa = PBXFileReference; name = "testapp-Bridging-Header.h"; path = "testapp/testapp-Bridging-Header.h"; sourceTree = ""; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; explicitFileType = undefined; includeInIndex = 0; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + 13B07F8C1A680F5B00A75B9A /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 96905EF65AED1B983A6B3ABC /* libPods-testapp.a in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 13B07FAE1A68108700A75B9A /* testapp */ = { + isa = PBXGroup; + children = ( + BB2F792B24A3F905000567C9 /* Supporting */, + 008F07F21AC5B25A0029DE68 /* main.jsbundle */, + 13B07FAF1A68108700A75B9A /* AppDelegate.h */, + 13B07FB01A68108700A75B9A /* AppDelegate.m */, + 13B07FB51A68108700A75B9A /* Images.xcassets */, + 13B07FB61A68108700A75B9A /* Info.plist */, + 13B07FB71A68108700A75B9A /* main.m */, + AA286B85B6C04FC6940260E9 /* SplashScreen.storyboard */, + C911AA20BDD841449D1CE041 /* noop-file.swift */, + 0962172184234A4793928022 /* testapp-Bridging-Header.h */, + ); + name = testapp; + sourceTree = ""; + }; + 2D16E6871FA4F8E400B85C8A /* Frameworks */ = { + isa = PBXGroup; + children = ( + ED297162215061F000B7C4FE /* JavaScriptCore.framework */, + 58EEBF8E8E6FB1BC6CAF49B5 /* libPods-testapp.a */, + ); + name = Frameworks; + sourceTree = ""; + }; + 832341AE1AAA6A7D00B99B32 /* Libraries */ = { + isa = PBXGroup; + children = ( + ); + name = Libraries; + sourceTree = ""; + }; + 83CBB9F61A601CBA00E9B192 = { + isa = PBXGroup; + children = ( + 13B07FAE1A68108700A75B9A /* testapp */, + 832341AE1AAA6A7D00B99B32 /* Libraries */, + 83CBBA001A601CBA00E9B192 /* Products */, + 2D16E6871FA4F8E400B85C8A /* Frameworks */, + D65327D7A22EEC0BE12398D9 /* Pods */, + D7E4C46ADA2E9064B798F356 /* ExpoModulesProviders */, + ); + indentWidth = 2; + sourceTree = ""; + tabWidth = 2; + usesTabs = 0; + }; + 83CBBA001A601CBA00E9B192 /* Products */ = { + isa = PBXGroup; + children = ( + 13B07F961A680F5B00A75B9A /* testapp.app */, + ); + name = Products; + sourceTree = ""; + }; + 92DBD88DE9BF7D494EA9DA96 /* testapp */ = { + isa = PBXGroup; + children = ( + FAC715A2D49A985799AEE119 /* ExpoModulesProvider.swift */, + ); + name = testapp; + sourceTree = ""; + }; + BB2F792B24A3F905000567C9 /* Supporting */ = { + isa = PBXGroup; + children = ( + BB2F792C24A3F905000567C9 /* Expo.plist */, + ); + name = Supporting; + path = testapp/Supporting; + sourceTree = ""; + }; + D65327D7A22EEC0BE12398D9 /* Pods */ = { + isa = PBXGroup; + children = ( + 6C2E3173556A471DD304B334 /* Pods-testapp.debug.xcconfig */, + 7A4D352CD337FB3A3BF06240 /* Pods-testapp.release.xcconfig */, + ); + path = Pods; + sourceTree = ""; + }; + D7E4C46ADA2E9064B798F356 /* ExpoModulesProviders */ = { + isa = PBXGroup; + children = ( + 92DBD88DE9BF7D494EA9DA96 /* testapp */, + ); + name = ExpoModulesProviders; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 13B07F861A680F5B00A75B9A /* testapp */ = { + isa = PBXNativeTarget; + buildConfigurationList = 13B07F931A680F5B00A75B9A /* Build configuration list for PBXNativeTarget "testapp" */; + buildPhases = ( + 08A4A3CD28434E44B6B9DE2E /* [CP] Check Pods Manifest.lock */, + FD10A7F022414F080027D42C /* Start Packager */, + 13B07F871A680F5B00A75B9A /* Sources */, + 13B07F8C1A680F5B00A75B9A /* Frameworks */, + 13B07F8E1A680F5B00A75B9A /* Resources */, + 00DD1BFF1BD5951E006B06BC /* Bundle React Native code and images */, + 800E24972A6A228C8D4807E9 /* [CP] Copy Pods Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = testapp; + productName = testapp; + productReference = 13B07F961A680F5B00A75B9A /* testapp.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 83CBB9F71A601CBA00E9B192 /* Project object */ = { + isa = PBXProject; + attributes = { + LastUpgradeCheck = 1130; + TargetAttributes = { + 13B07F861A680F5B00A75B9A = { + LastSwiftMigration = 1250; + }; + }; + }; + buildConfigurationList = 83CBB9FA1A601CBA00E9B192 /* Build configuration list for PBXProject "testapp" */; + compatibilityVersion = "Xcode 3.2"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = 83CBB9F61A601CBA00E9B192; + productRefGroup = 83CBBA001A601CBA00E9B192 /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 13B07F861A680F5B00A75B9A /* testapp */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 13B07F8E1A680F5B00A75B9A /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + BB2F792D24A3F905000567C9 /* Expo.plist in Resources */, + 13B07FBF1A68108700A75B9A /* Images.xcassets in Resources */, + 3E461D99554A48A4959DE609 /* SplashScreen.storyboard in Resources */, + C9A08D1C39C94E5D824DFA3F /* testapp-Bridging-Header.h in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXShellScriptBuildPhase section */ + 00DD1BFF1BD5951E006B06BC /* Bundle React Native code and images */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + ); + name = "Bundle React Native code and images"; + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "export NODE_BINARY=node\n\n# The project root by default is one level up from the ios directory\nexport PROJECT_ROOT=\"$PROJECT_DIR\"/..\n\n`node --print \"require('path').dirname(require.resolve('react-native/package.json')) + '/scripts/react-native-xcode.sh'\"`\n"; + }; + 08A4A3CD28434E44B6B9DE2E /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-testapp-checkManifestLockResult.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; + }; + 800E24972A6A228C8D4807E9 /* [CP] Copy Pods Resources */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-testapp/Pods-testapp-resources.sh", + "${PODS_CONFIGURATION_BUILD_DIR}/EXConstants/EXConstants.bundle", + "${PODS_CONFIGURATION_BUILD_DIR}/EXUpdates/EXUpdates.bundle", + "${PODS_CONFIGURATION_BUILD_DIR}/React-Core/AccessibilityResources.bundle", + ); + name = "[CP] Copy Pods Resources"; + outputPaths = ( + "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/EXConstants.bundle", + "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/EXUpdates.bundle", + "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/AccessibilityResources.bundle", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-testapp/Pods-testapp-resources.sh\"\n"; + showEnvVarsInLog = 0; + }; + FD10A7F022414F080027D42C /* Start Packager */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + ); + name = "Start Packager"; + outputFileListPaths = ( + ); + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "export RCT_METRO_PORT=\"${RCT_METRO_PORT:=8081}\"\necho \"export RCT_METRO_PORT=${RCT_METRO_PORT}\" > `node --print \"require('path').dirname(require.resolve('react-native/package.json')) + '/scripts/.packager.env'\"`\nif [ -z \"${RCT_NO_LAUNCH_PACKAGER+xxx}\" ] ; then\n if nc -w 5 -z localhost ${RCT_METRO_PORT} ; then\n if ! curl -s \"http://localhost:${RCT_METRO_PORT}/status\" | grep -q \"packager-status:running\" ; then\n echo \"Port ${RCT_METRO_PORT} already in use, packager is either not running or not running correctly\"\n exit 2\n fi\n else\n open `node --print \"require('path').dirname(require.resolve('react-native/package.json')) + '/scripts/launchPackager.command'\"` || echo \"Can't start packager automatically\"\n fi\nfi\n"; + showEnvVarsInLog = 0; + }; +/* End PBXShellScriptBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 13B07F871A680F5B00A75B9A /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 13B07FBC1A68108700A75B9A /* AppDelegate.m in Sources */, + 13B07FC11A68108700A75B9A /* main.m in Sources */, + B18059E884C0ABDD17F3DC3D /* ExpoModulesProvider.swift in Sources */, + E112987CA504453081AC7CD8 /* noop-file.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin XCBuildConfiguration section */ + 13B07F941A680F5B00A75B9A /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 6C2E3173556A471DD304B334 /* Pods-testapp.debug.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CURRENT_PROJECT_VERSION = 1; + ENABLE_BITCODE = NO; + GCC_PREPROCESSOR_DEFINITIONS = ( + "$(inherited)", + "FB_SONARKIT_ENABLED=1", + ); + INFOPLIST_FILE = testapp/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 12.0; + LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; + OTHER_LDFLAGS = ( + "$(inherited)", + "-ObjC", + "-lc++", + ); + PRODUCT_BUNDLE_IDENTIFIER = "org.reactjs.native.example.testapp.turtlev2"; + PRODUCT_NAME = turtlev2; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + TARGETED_DEVICE_FAMILY = "1,2"; + SWIFT_OBJC_BRIDGING_HEADER = testapp/testapp-Bridging-Header.h; + CODE_SIGN_ENTITLEMENTS = testapp/turtlev2.entitlements; + }; + name = Debug; + }; + 13B07F951A680F5B00A75B9A /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7A4D352CD337FB3A3BF06240 /* Pods-testapp.release.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CURRENT_PROJECT_VERSION = 1; + INFOPLIST_FILE = testapp/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 12.0; + LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; + OTHER_LDFLAGS = ( + "$(inherited)", + "-ObjC", + "-lc++", + ); + PRODUCT_BUNDLE_IDENTIFIER = "org.reactjs.native.example.testapp.turtlev2"; + PRODUCT_NAME = turtlev2; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + TARGETED_DEVICE_FAMILY = "1,2"; + SWIFT_OBJC_BRIDGING_HEADER = testapp/testapp-Bridging-Header.h; + CODE_SIGN_ENTITLEMENTS = testapp/turtlev2.entitlements; + }; + name = Release; + }; + 83CBBA201A601CBA00E9B192 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_SYMBOLS_PRIVATE_EXTERN = NO; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 12.0; + LD_RUNPATH_SEARCH_PATHS = "/usr/lib/swift $(inherited)"; + LIBRARY_SEARCH_PATHS = "\"$(inherited)\""; + MTL_ENABLE_DEBUG_INFO = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = iphoneos; + CODE_SIGN_ENTITLEMENTS = testapp/turtlev2.entitlements; + }; + name = Debug; + }; + 83CBBA211A601CBA00E9B192 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = YES; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 12.0; + LD_RUNPATH_SEARCH_PATHS = "/usr/lib/swift $(inherited)"; + LIBRARY_SEARCH_PATHS = "\"$(inherited)\""; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = iphoneos; + VALIDATE_PRODUCT = YES; + CODE_SIGN_ENTITLEMENTS = testapp/turtlev2.entitlements; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 13B07F931A680F5B00A75B9A /* Build configuration list for PBXNativeTarget "testapp" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 13B07F941A680F5B00A75B9A /* Debug */, + 13B07F951A680F5B00A75B9A /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 83CBB9FA1A601CBA00E9B192 /* Build configuration list for PBXProject "testapp" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 83CBBA201A601CBA00E9B192 /* Debug */, + 83CBBA211A601CBA00E9B192 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + }; + rootObject = 83CBB9F71A601CBA00E9B192 /* Project object */; +} diff --git a/packages/build-tools/src/steps/utils/ios/configure.ts b/packages/build-tools/src/steps/utils/ios/configure.ts new file mode 100644 index 0000000000..aac4cc6ea9 --- /dev/null +++ b/packages/build-tools/src/steps/utils/ios/configure.ts @@ -0,0 +1,107 @@ +import path from 'path'; + +import { IOSConfig } from '@expo/config-plugins'; +import uniq from 'lodash/uniq'; +import fs from 'fs-extra'; +import plist from '@expo/plist'; +import { bunyan } from '@expo/logger'; + +import { Credentials } from './credentials/manager'; + +export async function configureCredentialsAsync( + logger: bunyan, + workingDir: string, + { + credentials, + buildConfiguration, + }: { + credentials: Credentials; + buildConfiguration: string; + } +): Promise { + const targetNames = Object.keys(credentials.targetProvisioningProfiles); + for (const targetName of targetNames) { + const profile = credentials.targetProvisioningProfiles[targetName]; + logger.info( + `Assigning provisioning profile '${profile.name}' (Apple Team ID: ${profile.teamId}) to target '${targetName}'` + ); + IOSConfig.ProvisioningProfile.setProvisioningProfileForPbxproj(workingDir, { + targetName, + profileName: profile.name, + appleTeamId: profile.teamId, + buildConfiguration, + }); + } +} + +export async function updateVersionsAsync( + logger: bunyan, + workingDir: string, + { + buildNumber, + appVersion, + }: { + buildNumber?: string; + appVersion?: string; + }, + { + targetNames, + buildConfiguration, + }: { + targetNames: string[]; + buildConfiguration: string; + } +): Promise { + const project = IOSConfig.XcodeUtils.getPbxproj(workingDir); + const iosDir = path.join(workingDir, 'ios'); + + const infoPlistPaths: string[] = []; + for (const targetName of targetNames) { + const xcBuildConfiguration = IOSConfig.Target.getXCBuildConfigurationFromPbxproj(project, { + targetName, + buildConfiguration, + }); + const infoPlist = xcBuildConfiguration.buildSettings.INFOPLIST_FILE; + if (infoPlist) { + const evaluatedInfoPlistPath = trimQuotes( + evaluateTemplateString(infoPlist, { + SRCROOT: iosDir, + }) + ); + const absolutePath = path.isAbsolute(evaluatedInfoPlistPath) + ? evaluatedInfoPlistPath + : path.join(iosDir, evaluatedInfoPlistPath); + infoPlistPaths.push(path.normalize(absolutePath)); + } + } + const uniqueInfoPlistPaths = uniq(infoPlistPaths); + for (const infoPlistPath of uniqueInfoPlistPaths) { + logger.info(`Updating versions in ${infoPlistPath}`); + const infoPlistRaw = await fs.readFile(infoPlistPath, 'utf-8'); + const infoPlist = plist.parse(infoPlistRaw) as IOSConfig.InfoPlist; + if (buildNumber) { + infoPlist.CFBundleVersion = buildNumber; + } + if (appVersion) { + infoPlist.CFBundleShortVersionString = appVersion; + } + await fs.writeFile(infoPlistPath, plist.build(infoPlist)); + } +} + +function trimQuotes(s: string): string { + return s?.startsWith('"') && s.endsWith('"') ? s.slice(1, -1) : s; +} + +export function evaluateTemplateString(s: string, buildSettings: Record): string { + // necessary because buildSettings might be XCBuildConfiguration['buildSettings'] which is not a plain object + const vars = { ...buildSettings }; + return s.replace(/\$\((\w+)\)/g, (match, key) => { + if (vars.hasOwnProperty(key)) { + const value = String(vars[key]); + return trimQuotes(value); + } else { + return match; + } + }); +} diff --git a/packages/build-tools/src/steps/utils/ios/credentials/__integration-tests__/keychain.test.ios.ts b/packages/build-tools/src/steps/utils/ios/credentials/__integration-tests__/keychain.test.ios.ts new file mode 100644 index 0000000000..6159a80449 --- /dev/null +++ b/packages/build-tools/src/steps/utils/ios/credentials/__integration-tests__/keychain.test.ios.ts @@ -0,0 +1,67 @@ +import os from 'os'; +import path from 'path'; + +import { createLogger } from '@expo/logger'; +import fs from 'fs-extra'; +import { v4 as uuid } from 'uuid'; + +import Keychain from '../keychain'; +import { distributionCertificate } from '../__tests__/fixtures'; + +const mockLogger = createLogger({ name: 'mock-logger' }); + +jest.setTimeout(60 * 1000); + +// Those are in fact "real" tests in that they really execute the `fastlane` commands. +// We need the JS code to modify the file system, so we need to mock it. +jest.unmock('fs'); + +describe('Keychain class', () => { + describe('ensureCertificateImported method', () => { + let keychain: Keychain; + const certificatePath = path.join(os.tmpdir(), `cert-${uuid()}.p12`); + + beforeAll(async () => { + await fs.writeFile( + certificatePath, + new Uint8Array(Buffer.from(distributionCertificate.dataBase64, 'base64')) + ); + }); + + afterAll(async () => { + await fs.remove(certificatePath); + }); + + beforeEach(async () => { + keychain = new Keychain(); + await keychain.create(mockLogger); + }); + + afterEach(async () => { + await keychain.destroy(mockLogger); + }); + + it("should throw an error if the certificate hasn't been imported", async () => { + await expect( + keychain.ensureCertificateImported( + distributionCertificate.teamId, + distributionCertificate.fingerprint + ) + ).rejects.toThrowError(/hasn't been imported successfully/); + }); + + it("shouldn't throw any error if the certificate has been imported successfully", async () => { + await keychain.importCertificate( + mockLogger, + certificatePath, + distributionCertificate.password + ); + await expect( + keychain.ensureCertificateImported( + distributionCertificate.teamId, + distributionCertificate.fingerprint + ) + ).resolves.not.toThrow(); + }); + }); +}); diff --git a/packages/build-tools/src/steps/utils/ios/credentials/__integration-tests__/manager.test.ios.ts b/packages/build-tools/src/steps/utils/ios/credentials/__integration-tests__/manager.test.ios.ts new file mode 100644 index 0000000000..91b9948a0b --- /dev/null +++ b/packages/build-tools/src/steps/utils/ios/credentials/__integration-tests__/manager.test.ios.ts @@ -0,0 +1,90 @@ +import assert from 'assert'; +import { randomUUID } from 'crypto'; + +import { ArchiveSourceType, Ios, Platform, Workflow } from '@expo/eas-build-job'; +import { BuildMode, BuildTrigger } from '@expo/eas-build-job/dist/common'; +import { createLogger } from '@expo/logger'; + +import { distributionCertificate, provisioningProfile } from '../__tests__/fixtures'; +import IosCredentialsManager from '../manager'; + +jest.setTimeout(60 * 1000); + +// Those are in fact "real" tests in that they really execute the `fastlane` commands. +// We need the JS code to modify the file system, so we need to mock it. +jest.unmock('fs'); + +const mockLogger = createLogger({ name: 'mock-logger' }); + +const iosCredentials: Ios.BuildCredentials = { + testapp: { + provisioningProfileBase64: '', + distributionCertificate: { + dataBase64: '', + password: '', + }, + }, +}; + +function createTestIosJob({ + buildCredentials = iosCredentials, +}: { + buildCredentials?: Ios.BuildCredentials; +} = {}): Ios.Job { + return { + mode: BuildMode.BUILD, + platform: Platform.IOS, + triggeredBy: BuildTrigger.EAS_CLI, + type: Workflow.GENERIC, + projectArchive: { + type: ArchiveSourceType.URL, + url: 'https://turtle-v2-test-fixtures.s3.us-east-2.amazonaws.com/project.tar.gz', + }, + scheme: 'turtlebareproj', + buildConfiguration: 'Release', + applicationArchivePath: './ios/build/*.ipa', + projectRootDirectory: '.', + cache: { + clear: false, + disabled: false, + paths: [], + }, + secrets: { + buildCredentials, + }, + appId: randomUUID(), + initiatingUserId: randomUUID(), + }; +} + +describe(IosCredentialsManager, () => { + describe('.prepare', () => { + it('should prepare credentials for the build process', async () => { + const targetName = 'testapp'; + const job = createTestIosJob({ + buildCredentials: { + [targetName]: { + distributionCertificate, + provisioningProfileBase64: provisioningProfile.dataBase64, + }, + }, + }); + + assert(job.secrets, 'secrets must be defined'); + assert(job.secrets.buildCredentials, 'buildCredentials must be defined'); + const manager = new IosCredentialsManager(job.secrets.buildCredentials); + const credentials = await manager.prepare(mockLogger); + await manager.cleanUp(mockLogger); + + assert(credentials, 'credentials must be defined'); + + expect(credentials.teamId).toBe('QL76XYH73P'); + expect(credentials.distributionType).toBe('app-store'); + + const profile = credentials.targetProvisioningProfiles[targetName]; + expect(profile.bundleIdentifier).toBe('org.reactjs.native.example.testapp.turtlev2'); + expect(profile.distributionType).toBe('app-store'); + expect(profile.teamId).toBe('QL76XYH73P'); + }); + }); +}); diff --git a/packages/build-tools/src/steps/utils/ios/credentials/__tests__/distributionCertificate.test.ios.ts b/packages/build-tools/src/steps/utils/ios/credentials/__tests__/distributionCertificate.test.ios.ts new file mode 100644 index 0000000000..0e9f16d108 --- /dev/null +++ b/packages/build-tools/src/steps/utils/ios/credentials/__tests__/distributionCertificate.test.ios.ts @@ -0,0 +1,38 @@ +import fs from 'fs-extra'; +import { vol } from 'memfs'; + +import { getFingerprint, getCommonName } from '../distributionCertificate'; + +import { distributionCertificate } from './fixtures'; + +describe('distributionCertificate module', () => { + describe(getFingerprint, () => { + it('calculates the certificate fingerprint', () => { + const fingerprint = getFingerprint({ + dataBase64: distributionCertificate.dataBase64, + password: distributionCertificate.password, + }); + expect(fingerprint).toEqual(distributionCertificate.fingerprint); + }); + + it('should throw an error if the password is incorrect', () => { + expect(() => { + getFingerprint({ + dataBase64: distributionCertificate.dataBase64, + password: 'incorrect', + }); + }).toThrowError(/password.*invalid/); + }); + }); + describe(getCommonName, () => { + it('returns cert common name', async () => { + vol.fromNestedJSON({ '/tmp': {} }); + const commonName = getCommonName({ + dataBase64: distributionCertificate.dataBase64, + password: distributionCertificate.password, + }); + await fs.writeFile('/tmp/a', commonName, 'utf-8'); + expect(commonName).toBe(distributionCertificate.commonName); + }); + }); +}); diff --git a/packages/build-tools/src/steps/utils/ios/credentials/__tests__/fixtures.ts b/packages/build-tools/src/steps/utils/ios/credentials/__tests__/fixtures.ts new file mode 100644 index 0000000000..e08105515c --- /dev/null +++ b/packages/build-tools/src/steps/utils/ios/credentials/__tests__/fixtures.ts @@ -0,0 +1,34 @@ +interface DistributionCertificateData { + dataBase64: string; + password: string; + serialNumber: string; + fingerprint: string; + teamId: string; + commonName: string; +} + +interface ProvisioningProfileData { + id?: string; + dataBase64: string; + certFingerprint: string; +} + +// TODO: regenerate after Jun 4, 2025 +// this certificate is invalidated +export const distributionCertificate: DistributionCertificateData = { + dataBase64: `MIIL8AIBAzCCC7YGCSqGSIb3DQEHAaCCC6cEggujMIILnzCCBj8GCSqGSIb3DQEHAaCCBjAEggYsMIIGKDCCBiQGCyqGSIb3DQEMCgEDoIIF1TCCBdEGCiqGSIb3DQEJFgGgggXBBIIFvTCCBbkwggShoAMCAQICEAF/s+5UwyPCBGg0yp7+wokwDQYJKoZIhvcNAQELBQAwdTFEMEIGA1UEAww7QXBwbGUgV29ybGR3aWRlIERldmVsb3BlciBSZWxhdGlvbnMgQ2VydGlmaWNhdGlvbiBBdXRob3JpdHkxCzAJBgNVBAsMAkczMRMwEQYDVQQKDApBcHBsZSBJbmMuMQswCQYDVQQGEwJVUzAeFw0yNTA2MTAwODIyNTBaFw0yNjA2MTAwODIyNDlaMIGUMRowGAYKCZImiZPyLGQBAQwKUUw3NlhZSDczUDE6MDgGA1UEAwwxaVBob25lIERpc3RyaWJ1dGlvbjogQWxpY2phIFdhcmNoYcWCIChRTDc2WFlINzNQKTETMBEGA1UECwwKUUw3NlhZSDczUDEYMBYGA1UECgwPQWxpY2phIFdhcmNoYcWCMQswCQYDVQQGEwJVUzCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAM45oAP6+kc1OuyEx99inQVKKxk5jhwD4pSIxGl4L4PoOoCvc3ao++IMg5J3Euv9k/78jV//FurL3mQEIjg5HcjIYsXldJk0RFehsswQSV//8T0aFVjFweM51wsxpe7iCb/uVwYJVnu+g/DK+wKi2IWaNOHnSdwNOODPxftzbvtgEm7RFNxtW5boE3ulmKopvAcZR3YD7st13F6VBnJYAn5m+9OKSqSRCF+u8yQ7XRzSj2xXfrR/bPDW2fianIQPhveui1L65jt1s1z30yoO97FXenE+PidZihrVcBAV8myMmi5lX5jPICP2D6npbX5t9gQswIdhjC6WpnybL/PzSvECAwEAAaOCAiMwggIfMAwGA1UdEwEB/wQCMAAwHwYDVR0jBBgwFoAUCf7AFZD5r2QKkhK5JihjDJfsp7IwcAYIKwYBBQUHAQEEZDBiMC0GCCsGAQUFBzAChiFodHRwOi8vY2VydHMuYXBwbGUuY29tL3d3ZHJnMy5kZXIwMQYIKwYBBQUHMAGGJWh0dHA6Ly9vY3NwLmFwcGxlLmNvbS9vY3NwMDMtd3dkcmczMDIwggEeBgNVHSAEggEVMIIBETCCAQ0GCSqGSIb3Y2QFATCB/zCBwwYIKwYBBQUHAgIwgbYMgbNSZWxpYW5jZSBvbiB0aGlzIGNlcnRpZmljYXRlIGJ5IGFueSBwYXJ0eSBhc3N1bWVzIGFjY2VwdGFuY2Ugb2YgdGhlIHRoZW4gYXBwbGljYWJsZSBzdGFuZGFyZCB0ZXJtcyBhbmQgY29uZGl0aW9ucyBvZiB1c2UsIGNlcnRpZmljYXRlIHBvbGljeSBhbmQgY2VydGlmaWNhdGlvbiBwcmFjdGljZSBzdGF0ZW1lbnRzLjA3BggrBgEFBQcCARYraHR0cHM6Ly93d3cuYXBwbGUuY29tL2NlcnRpZmljYXRlYXV0aG9yaXR5LzAWBgNVHSUBAf8EDDAKBggrBgEFBQcDAzAdBgNVHQ4EFgQUpBXWH7c6kvuNVDtbUg5ABmmIi4UwDgYDVR0PAQH/BAQDAgeAMBMGCiqGSIb3Y2QGAQQBAf8EAgUAMA0GCSqGSIb3DQEBCwUAA4IBAQAurJLhdKe0U02JhpX65LUUJSyk1WmholtK1DECIqL6RD6f80toaLpmGlvZ0VzZgn5ceSPgIpqcQGcgOqr4LdDnBj/C3y1mfcvgaAHD6ksKKkwmU79t2bSMRrLi4SdtPFrXePMcn8py4awMeGOYi9Z1to/Bh/x6+H0mkXVs2D0R1+O+pZxKXjT9Eg96o2p9j7RRuMVQ1yT6pKT7q1T9OEmHum/Llld6uS7Eg+Uyj/N2aZvJrQRRaSlsOwIc7IR7a9bkSHC2RSKmKVKdY0wuxOAz2TIqRMkDbxsJMqxSJdj0FyCLyZkGQT72EMDHTfbA9054mvn/IxJ5YsEv37yiOvLfMTwwIwYJKoZIhvcNAQkVMRYEFGH4WlEGNyq60eae8sEVcSzBwXAsMBUGCSqGSIb3DQEJFDEIHgYAawBlAHkwggVYBgkqhkiG9w0BBwGgggVJBIIFRTCCBUEwggU9BgsqhkiG9w0BDAoBAqCCBO4wggTqMBwGCiqGSIb3DQEMAQMwDgQIoJXy6GSRRgACAggABIIEyBKWMl2OAyV7nWfoWPFy5+Qr3wookZAonzqTLml+lWg6EyopErj3S5PBI2LstwNLcapY3BG+ez5TUs1sPEbC6WtpNivJb7mURb8nw/+dlIojQS4DSqoIN4VXRI8uBbXf2jl09LC9qoBU9as1USqDRMOpIgMbvA4JPBtUjgxyeSz2okVeKvoZ92ynrrx+Uv4NCpHIPUthNcUUbPg0NZtLoK+JqnxnkbSKaKAaCweGRz18cH9R2gM0u3LVIduXIFw5JeUNakh36fD8Q4Z+Heby//wu+uM12z16h+4DJfp+YpeJaLj0up6+qSh2A/hRjC7FRHY3d9M8t6+LcWXHqv5mI8Wf0k6/3UFY5OnQGVDRmPBSLceC2bxFJPlSHX4qeic6IuYHocn9eHZgIOZuA8X3s87iBW6WHnwLraMThjFgv8kZYZfLO6fEX49zxMVUt/tllZA6xroWuPGb1+r313bteqlp6r/XkRmKDN5f0ypgvTM7doSTzT5sLw8TvF0B0Kl21a46EWyYm3aINtQfUGrtgMDEdhLXiKMzx+lRe87J3kWNE/gDCrJOmlRiOWg0zWqEzghO7baFRmUheZ4TBbP7oCBwZ1TAa/wzwaQ3+gi4ycCKs5YVJTO0Z8ld5Sjyqu050D4ElpGt+EQTlSqvk4157RPvQYZFzSpsmpbdhhUeWrmXViHt+8fwJC4K4mbZZzjJU1JyxlgJYrh4dSAmdgoQJd4VQFcIt19qZaCLZ0HCTdNHtbMuLaFKWg+FPiAUcvLjv5e+UC3+dYuR3IoncuyveK8bcE0MEIbEgAgFApDmZi/EUlnmlOSaGH4aKDBYuWODFEwSWI+ajrpwHDMBAM+zO3bDSDbTGm/AHtJOQ+cfgfvzkr/1jUkFuMfyqCFaSv+VK7yPBb5B2n4iLhh9jTFOztC1oVZlT0qZM99K9wgAFyo0iD975BEkhtl6D+alRjU3gyQ+2lcy3IHwE2MXYI9sH19gUdx6gu1kFCP4ukw1fo81aqHMiDSNjHCSF6ydsw2m8wCV+Zd1nws2tA6HehHFi8jmnKMaJ0fKHymVoDKHjSUxWHJRFVBhPUUOgxhaNVDEQBFuAqRa8Emk2XFNCx5BQCMaJsTz8mA4NpKuEh1NyitiVVN4ezPfkQ/4KKlC//y3nInR1U3qMRc14g+uUuMSmcQJrqwbF/RCxA7lAivOzKwN7N01iEjo9u6FLn4yaVd8Y+0h92+0nYzx7I5Gr+KrGGbMY35+ytntZ4/sJ9c3WR0gJUVPGgYy3tLBS4UOaa/iH0c4R4lHEJp+Wm/XrMQvHfNTQuQrcjKW4M/6dduXtcCOqstsT2SnZnZXkFH9SU9TdP7PFVXFeyer8b86fPmcR1cXxCfaNGVzWRXktFzseM31c4ZQK3sxKk8OvH6iUx1QJzjc3NGI7jczGk0HO7NSLxeF6aaeJ+PivICtNg/X95NVfCCqUrrkrsuPTYQOOeISHTELL8CAL6DC1V11LcFYEqaKwovWDyrdqu1LLA6NHWbrvdPvQosbB2Lq3vB76a2x/rIenQiAEyluB6aK7AEdfd1S+AQT5QpHwL5HayYtW57FF91yfrmHCwwmuBB4Cj1beE4InjwvNyxIcjxSvDUfl+LplIh5kIimOjE8MCMGCSqGSIb3DQEJFTEWBBRh+FpRBjcqutHmnvLBFXEswcFwLDAVBgkqhkiG9w0BCRQxCB4GAGsAZQB5MDEwITAJBgUrDgMCGgUABBTkYbJeShZO4HeTNWVcl/DNvX0i8gQIYL7sJfr+fCcCAggA +`, + password: 'uCo32FiOY1sk5WLB3MjfZA==', + serialNumber: '017FB3EE54C323C2046834CA9EFEC289', + fingerprint: '61F85A5106372ABAD1E69EF2C115712CC1C1702C', + teamId: 'QL76XYH73P', + commonName: 'iPhone Distribution: Alicja Warchał (QL76XYH73P)', +}; + +// TODO: regenerate after Jun 4, 2025 +// this provisioning profile is invalidated +export const provisioningProfile: ProvisioningProfileData = { + id: '3D2QDBHRRD', + dataBase64: `MIIwKQYJKoZIhvcNAQcCoIIwGjCCMBYCAQExCzAJBgUrDgMCGgUAMIIgNgYJKoZIhvcNAQcBoIIgJwSCICM8P3htbCB2ZXJzaW9uPSIxLjAiIGVuY29kaW5nPSJVVEYtOCI/Pgo8IURPQ1RZUEUgcGxpc3QgUFVCTElDICItLy9BcHBsZS8vRFREIFBMSVNUIDEuMC8vRU4iICJodHRwOi8vd3d3LmFwcGxlLmNvbS9EVERzL1Byb3BlcnR5TGlzdC0xLjAuZHRkIj4KPHBsaXN0IHZlcnNpb249IjEuMCI+CjxkaWN0PgoJPGtleT5BcHBJRE5hbWU8L2tleT4KCTxzdHJpbmc+dGVzdGFwcCAzMTBlMGIzNzcxODQ5NWNlZWU0NDc3MGI2NzkzZWVkZjwvc3RyaW5nPgoJPGtleT5BcHBsaWNhdGlvbklkZW50aWZpZXJQcmVmaXg8L2tleT4KCTxhcnJheT4KCTxzdHJpbmc+UUw3NlhZSDczUDwvc3RyaW5nPgoJPC9hcnJheT4KCTxrZXk+Q3JlYXRpb25EYXRlPC9rZXk+Cgk8ZGF0ZT4yMDI1LTA2LTEwVDA4OjMzOjA0WjwvZGF0ZT4KCTxrZXk+UGxhdGZvcm08L2tleT4KCTxhcnJheT4KCQk8c3RyaW5nPmlPUzwvc3RyaW5nPgoJCTxzdHJpbmc+eHJPUzwvc3RyaW5nPgoJCTxzdHJpbmc+dmlzaW9uT1M8L3N0cmluZz4KCTwvYXJyYXk+Cgk8a2V5PklzWGNvZGVNYW5hZ2VkPC9rZXk+Cgk8ZmFsc2UvPgoJPGtleT5EZXZlbG9wZXJDZXJ0aWZpY2F0ZXM8L2tleT4KCTxhcnJheT4KCQk8ZGF0YT5NSUlGdVRDQ0JLR2dBd0lCQWdJUUFYK3o3bFRESThJRWFEVEtudjdDaVRBTkJna3Foa2lHOXcwQkFRc0ZBREIxTVVRd1FnWURWUVFERER0QmNIQnNaU0JYYjNKc1pIZHBaR1VnUkdWMlpXeHZjR1Z5SUZKbGJHRjBhVzl1Y3lCRFpYSjBhV1pwWTJGMGFXOXVJRUYxZEdodmNtbDBlVEVMTUFrR0ExVUVDd3dDUnpNeEV6QVJCZ05WQkFvTUNrRndjR3hsSUVsdVl5NHhDekFKQmdOVkJBWVRBbFZUTUI0WERUSTFNRFl4TURBNE1qSTFNRm9YRFRJMk1EWXhNREE0TWpJME9Wb3dnWlF4R2pBWUJnb0praWFKay9Jc1pBRUJEQXBSVERjMldGbElOek5RTVRvd09BWURWUVFERERGcFVHaHZibVVnUkdsemRISnBZblYwYVc5dU9pQkJiR2xqYW1FZ1YyRnlZMmhoeFlJZ0tGRk1OelpZV1VnM00xQXBNUk13RVFZRFZRUUxEQXBSVERjMldGbElOek5RTVJnd0ZnWURWUVFLREE5QmJHbGphbUVnVjJGeVkyaGh4WUl4Q3pBSkJnTlZCQVlUQWxWVE1JSUJJakFOQmdrcWhraUc5dzBCQVFFRkFBT0NBUThBTUlJQkNnS0NBUUVBemptZ0EvcjZSelU2N0lUSDMyS2RCVW9yR1RtT0hBUGlsSWpFYVhndmcrZzZnSzl6ZHFqNzRneURrbmNTNi8yVC92eU5YLzhXNnN2ZVpBUWlPRGtkeU1oaXhlVjBtVFJFVjZHeXpCQkpYLy94UFJvVldNWEI0em5YQ3pHbDd1SUp2KzVYQmdsV2U3NkQ4TXI3QXFMWWhabzA0ZWRKM0EwNDRNL0YrM051KzJBU2J0RVUzRzFibHVnVGU2V1lxaW04QnhsSGRnUHV5M1hjWHBVR2NsZ0NmbWI3MDRwS3BKRUlYNjd6SkR0ZEhOS1BiRmQrdEg5czhOYlorSnFjaEErRzk2NkxVdnJtTzNXelhQZlRLZzczc1ZkNmNUNCtKMW1LR3RWd0VCWHliSXlhTG1WZm1NOGdJL1lQcWVsdGZtMzJCQ3pBaDJHTUxwYW1mSnN2OC9OSzhRSURBUUFCbzRJQ0l6Q0NBaDh3REFZRFZSMFRBUUgvQkFJd0FEQWZCZ05WSFNNRUdEQVdnQlFKL3NBVmtQbXZaQXFTRXJrbUtHTU1sK3luc2pCd0JnZ3JCZ0VGQlFjQkFRUmtNR0l3TFFZSUt3WUJCUVVITUFLR0lXaDBkSEE2THk5alpYSjBjeTVoY0hCc1pTNWpiMjB2ZDNka2NtY3pMbVJsY2pBeEJnZ3JCZ0VGQlFjd0FZWWxhSFIwY0RvdkwyOWpjM0F1WVhCd2JHVXVZMjl0TDI5amMzQXdNeTEzZDJSeVp6TXdNakNDQVI0R0ExVWRJQVNDQVJVd2dnRVJNSUlCRFFZSktvWklodmRqWkFVQk1JSC9NSUhEQmdnckJnRUZCUWNDQWpDQnRneUJzMUpsYkdsaGJtTmxJRzl1SUhSb2FYTWdZMlZ5ZEdsbWFXTmhkR1VnWW5rZ1lXNTVJSEJoY25SNUlHRnpjM1Z0WlhNZ1lXTmpaWEIwWVc1alpTQnZaaUIwYUdVZ2RHaGxiaUJoY0hCc2FXTmhZbXhsSUhOMFlXNWtZWEprSUhSbGNtMXpJR0Z1WkNCamIyNWthWFJwYjI1eklHOW1JSFZ6WlN3Z1kyVnlkR2xtYVdOaGRHVWdjRzlzYVdONUlHRnVaQ0JqWlhKMGFXWnBZMkYwYVc5dUlIQnlZV04wYVdObElITjBZWFJsYldWdWRITXVNRGNHQ0NzR0FRVUZCd0lCRml0b2RIUndjem92TDNkM2R5NWhjSEJzWlM1amIyMHZZMlZ5ZEdsbWFXTmhkR1ZoZFhSb2IzSnBkSGt2TUJZR0ExVWRKUUVCL3dRTU1Bb0dDQ3NHQVFVRkJ3TURNQjBHQTFVZERnUVdCQlNrRmRZZnR6cVMrNDFVTzF0U0RrQUdhWWlMaFRBT0JnTlZIUThCQWY4RUJBTUNCNEF3RXdZS0tvWklodmRqWkFZQkJBRUIvd1FDQlFBd0RRWUpLb1pJaHZjTkFRRUxCUUFEZ2dFQkFDNnNrdUYwcDdSVFRZbUdsZnJrdFJRbExLVFZhYUdpVzByVU1RSWlvdnBFUHAvelMyaG91bVlhVzluUlhObUNmbHg1SStBaW1weEFaeUE2cXZndDBPY0dQOExmTFdaOXkrQm9BY1BxU3dvcVRDWlR2MjNadEl4R3N1TGhKMjA4V3RkNDh4eWZ5bkxockF4NFk1aUwxblcyajhHSC9IcjRmU2FSZFd6WVBSSFg0NzZsbkVwZU5QMFNEM3FqYW4yUHRGRzR4VkRYSlBxa3BQdXJWUDA0U1llNmI4dVdWM3E1THNTRDVUS1A4M1pwbThtdEJGRnBLV3c3QWh6c2hIdHIxdVJJY0xaRklxWXBVcDFqVEM3RTREUFpNaXBFeVFOdkd3a3lyRklsMlBRWElJdkptUVpCUHZZUXdNZE45c0QzVG5pYStmOGpFbmxpd1MvZnZLSTY4dDg9PC9kYXRhPgoJPC9hcnJheT4KCgk8a2V5PkRFUi1FbmNvZGVkLVByb2ZpbGU8L2tleT4KCTxkYXRhPk1JSU5pd1lKS29aSWh2Y05BUWNDb0lJTmZEQ0NEWGdDQVFFeER6QU5CZ2xnaGtnQlpRTUVBZ0VGQURDQ0EwWUdDU3FHU0liM0RRRUhBYUNDQXpjRWdnTXpNWUlETHpBTURBZFdaWEp6YVc5dUFnRUJNQkFNQ2xScGJXVlViMHhwZG1VQ0FnRnNNQk1NRGtseldHTnZaR1ZOWVc1aFoyVmtBUUVBTUJzTUNGUmxZVzFPWVcxbERBOUJiR2xqYW1FZ1YyRnlZMmhoeFlJd0hRd01RM0psWVhScGIyNUVZWFJsRncweU5UQTJNVEF3T0RNek1EUmFNQjRNRGxSbFlXMUpaR1Z1ZEdsbWFXVnlNQXdNQ2xGTU56WllXVWczTTFBd0h3d09SWGh3YVhKaGRHbHZia1JoZEdVWERUSTJNRFl4TURBNE1qSTBPVm93SUF3WFVISnZabWxzWlVScGMzUnlhV0oxZEdsdmJsUjVjR1VNQlZOVVQxSkZNQ0VNQ0ZCc1lYUm1iM0p0TUJVTUEybFBVd3dFZUhKUFV3d0lkbWx6YVc5dVQxTXdLd3diUVhCd2JHbGpZWFJwYjI1SlpHVnVkR2xtYVdWeVVISmxabWw0TUF3TUNsRk1OelpZV1VnM00xQXdMQXdFVlZWSlJBd2taR05pWW1OaE1EQXRPV0psWXkwME5HUTBMVGd6WkdVdE1tSmhZek5oTkRabFlUUmlNRFVNQ1VGd2NFbEVUbUZ0WlF3b2RHVnpkR0Z3Y0NBek1UQmxNR0l6TnpjeE9EUTVOV05sWldVME5EYzNNR0kyTnprelpXVmtaakE3REJWRVpYWmxiRzl3WlhKRFpYSjBhV1pwWTJGMFpYTXdJZ1FneGNRem5wZmlZeTFiVk1HdnZNZzFjNHN3OEZ6M1NiQUFtR1owbUdmdlFqWXdYUXdFVG1GdFpReFZLbHRsZUhCdlhTQnZjbWN1Y21WaFkzUnFjeTV1WVhScGRtVXVaWGhoYlhCc1pTNTBaWE4wWVhCd0xuUjFjblJzWlhZeUlFRndjRk4wYjNKbElESXdNalF0TURZdE1EUlVNVGc2TVRFNk1qWXVOakF5V2pDQ0FRWU1ERVZ1ZEdsMGJHVnRaVzUwYzNDQjlRSUJBYkNCN3pCUURCWmhjSEJzYVdOaGRHbHZiaTFwWkdWdWRHbG1hV1Z5RERaUlREYzJXRmxJTnpOUUxtOXlaeTV5WldGamRHcHpMbTVoZEdsMlpTNWxlR0Z0Y0d4bExuUmxjM1JoY0hBdWRIVnlkR3hsZGpJd0dBd1RZbVYwWVMxeVpYQnZjblJ6TFdGamRHbDJaUUVCL3pBeERDTmpiMjB1WVhCd2JHVXVaR1YyWld4dmNHVnlMblJsWVcwdGFXUmxiblJwWm1sbGNnd0tVVXczTmxoWlNEY3pVREFUREE1blpYUXRkR0Z6YXkxaGJHeHZkd0VCQURBNURCWnJaWGxqYUdGcGJpMWhZMk5sYzNNdFozSnZkWEJ6TUI4TURGRk1OelpZV1VnM00xQXVLZ3dQWTI5dExtRndjR3hsTG5SdmEyVnVvSUlJUERDQ0FrTXdnZ0hKb0FNQ0FRSUNDQzNGL0lqU3hVdVZNQW9HQ0NxR1NNNDlCQU1ETUdjeEd6QVpCZ05WQkFNTUVrRndjR3hsSUZKdmIzUWdRMEVnTFNCSE16RW1NQ1FHQTFVRUN3d2RRWEJ3YkdVZ1EyVnlkR2xtYVdOaGRHbHZiaUJCZFhSb2IzSnBkSGt4RXpBUkJnTlZCQW9NQ2tGd2NHeGxJRWx1WXk0eEN6QUpCZ05WQkFZVEFsVlRNQjRYRFRFME1EUXpNREU0TVRrd05sb1hEVE01TURRek1ERTRNVGt3Tmxvd1p6RWJNQmtHQTFVRUF3d1NRWEJ3YkdVZ1VtOXZkQ0JEUVNBdElFY3pNU1l3SkFZRFZRUUxEQjFCY0hCc1pTQkRaWEowYVdacFkyRjBhVzl1SUVGMWRHaHZjbWwwZVRFVE1CRUdBMVVFQ2d3S1FYQndiR1VnU1c1akxqRUxNQWtHQTFVRUJoTUNWVk13ZGpBUUJnY3Foa2pPUFFJQkJnVXJnUVFBSWdOaUFBU1k2Uzg5UUhLazdaTWljb0VUSE4wUWxmSEZvMDV4M0JRVzJRN2xwZ1VxZDJSN1gwNDQwN3NjUkxWLzlSKzJNbUpkeWVtRVcwOHdUeEZhQVAxWVdBeWw5UThzVFFkSEUzWGFsNWVYYnpGYzdTdWRleUE3MkxsVTJWNlpwRHBSQ2pHalFqQkFNQjBHQTFVZERnUVdCQlM3c042aFdET0ltcVNLbWQ2K3ZldXYyc3NrcXpBUEJnTlZIUk1CQWY4RUJUQURBUUgvTUE0R0ExVWREd0VCL3dRRUF3SUJCakFLQmdncWhrak9QUVFEQXdOb0FEQmxBakVBZytuQnhCWmVHbDAwR05udDcvUnNEZ0JHUzdqZnNrWVJ4US85NW5xTW9hWnJ6c0lEMUp6MWs4WjB1R3JmcWlNVkFqQnRab29ReXRRTjFFL05qVU0rdElwanBUTnU0MjNhRjdka0g4aFRKdm1JWW5RNUN4ZGJ5MUdvRE9nWUErZWlzaWd3Z2dMbU1JSUNiYUFEQWdFQ0FnZ3pEZTc0djB4b0xqQUtCZ2dxaGtqT1BRUURBekJuTVJzd0dRWURWUVFEREJKQmNIQnNaU0JTYjI5MElFTkJJQzBnUnpNeEpqQWtCZ05WQkFzTUhVRndjR3hsSUVObGNuUnBabWxqWVhScGIyNGdRWFYwYUc5eWFYUjVNUk13RVFZRFZRUUtEQXBCY0hCc1pTQkpibU11TVFzd0NRWURWUVFHRXdKVlV6QWVGdzB4TnpBeU1qSXlNakl6TWpKYUZ3MHpNakF5TVRnd01EQXdNREJhTUhJeEpqQWtCZ05WQkFNTUhVRndjR3hsSUZONWMzUmxiU0JKYm5SbFozSmhkR2x2YmlCRFFTQTBNU1l3SkFZRFZRUUxEQjFCY0hCc1pTQkRaWEowYVdacFkyRjBhVzl1SUVGMWRHaHZjbWwwZVRFVE1CRUdBMVVFQ2d3S1FYQndiR1VnU1c1akxqRUxNQWtHQTFVRUJoTUNWVk13V1RBVEJnY3Foa2pPUFFJQkJnZ3Foa2pPUFFNQkJ3TkNBQVFHYTZSV2IzMmZKOUhPTm82U0cxYk5WRFprU3NtVWFKbjZ5U0IrNHZWWUQ5emlhdXNaUnk4dTd6dWtBYlFCRTBSOFdpYXRvSndwSllybDVnWnZUM3hhbzRIM01JSDBNQThHQTFVZEV3RUIvd1FGTUFNQkFmOHdId1lEVlIwakJCZ3dGb0FVdTdEZW9WZ3ppSnFraXBuZXZyM3JyOXJMSktzd1JnWUlLd1lCQlFVSEFRRUVPakE0TURZR0NDc0dBUVVGQnpBQmhpcG9kSFJ3T2k4dmIyTnpjQzVoY0hCc1pTNWpiMjB2YjJOemNEQXpMV0Z3Y0d4bGNtOXZkR05oWnpNd053WURWUjBmQkRBd0xqQXNvQ3FnS0lZbWFIUjBjRG92TDJOeWJDNWhjSEJzWlM1amIyMHZZWEJ3YkdWeWIyOTBZMkZuTXk1amNtd3dIUVlEVlIwT0JCWUVGSHBIdWppS0ZTUklJa2JOdm84YUpIczBBeXBwTUE0R0ExVWREd0VCL3dRRUF3SUJCakFRQmdvcWhraUc5Mk5rQmdJUkJBSUZBREFLQmdncWhrak9QUVFEQXdObkFEQmtBakFWREttT3hxK1dhV3VubjkxYzFBTlpiSzVTMUdER2kzYmd0OFdpOFFsODRKcmphN0hqZkRIRUozcW5qb245cTNjQ01HRXpJUEVwLy9tSE1xNHB5R1E5ZG50UnBOSUNMM2ErWUNLUjhkVTZkZHkwNHNZcWx2N0dDZHhLVDlVazhQektzakNDQXdjd2dnS3RvQU1DQVFJQ0NCZUFxRFJtWk9yTE1Bb0dDQ3FHU000OUJBTUNNSEl4SmpBa0JnTlZCQU1NSFVGd2NHeGxJRk41YzNSbGJTQkpiblJsWjNKaGRHbHZiaUJEUVNBME1TWXdKQVlEVlFRTERCMUJjSEJzWlNCRFpYSjBhV1pwWTJGMGFXOXVJRUYxZEdodmNtbDBlVEVUTUJFR0ExVUVDZ3dLUVhCd2JHVWdTVzVqTGpFTE1Ba0dBMVVFQmhNQ1ZWTXdIaGNOTWpReE1USXdNRE15TURRMVdoY05Namd4TWpFME1UZ3dNRE13V2pCT01Tb3dLQVlEVlFRRERDRlhWMFJTSUZCeWIzWnBjMmx2Ym1sdVp5QlFjbTltYVd4bElGTnBaMjVwYm1jeEV6QVJCZ05WQkFvTUNrRndjR3hsSUVsdVl5NHhDekFKQmdOVkJBWVRBbFZUTUZrd0V3WUhLb1pJemowQ0FRWUlLb1pJemowREFRY0RRZ0FFOVpSYkRzSFVWRDhaM2taZ0pWOCtqaW5QYkUrVDFSeTNmYlpTWHgvaCsyTUJzb0licUtGT3ROUHQ3MWE1RjlTWXpXQzN6azZwdHpwWGRsUG9NME92ZmFPQ0FVOHdnZ0ZMTUF3R0ExVWRFd0VCL3dRQ01BQXdId1lEVlIwakJCZ3dGb0FVZWtlNk9Jb1ZKRWdpUnMyK2p4b2tlelFES21rd1FRWUlLd1lCQlFVSEFRRUVOVEF6TURFR0NDc0dBUVVGQnpBQmhpVm9kSFJ3T2k4dmIyTnpjQzVoY0hCc1pTNWpiMjB2YjJOemNEQXpMV0Z6YVdOaE5EQXpNSUdXQmdOVkhTQUVnWTR3Z1lzd2dZZ0dDU3FHU0liM1kyUUZBVEI3TUhrR0NDc0dBUVVGQndJQ01HME1hMVJvYVhNZ1kyVnlkR2xtYVdOaGRHVWdhWE1nZEc4Z1ltVWdkWE5sWkNCbGVHTnNkWE5wZG1Wc2VTQm1iM0lnWm5WdVkzUnBiMjV6SUdsdWRHVnlibUZzSUhSdklFRndjR3hsSUZCeWIyUjFZM1J6SUdGdVpDOXZjaUJCY0hCc1pTQndjbTlqWlhOelpYTXVNQjBHQTFVZERnUVdCQlRwVXM0TnNNYUlHbVZLdUpzUmovSGNIa2NVZkRBT0JnTlZIUThCQWY4RUJBTUNCNEF3RHdZSktvWklodmRqWkF3VEJBSUZBREFLQmdncWhrak9QUVFEQWdOSUFEQkZBaUVBNzVxOFhhQmFabXhrdWMwM2s2bFR0RGZGeDJ6aGcvb1hHdDRMUGRhcmFHWUNJSGdqMmNPSUIwazk5ZnVKajVCdVdPQlhuMlAvV09IV2oxeVlZOVk2UGorZE1ZSUIxakNDQWRJQ0FRRXdmakJ5TVNZd0pBWURWUVFEREIxQmNIQnNaU0JUZVhOMFpXMGdTVzUwWldkeVlYUnBiMjRnUTBFZ05ERW1NQ1FHQTFVRUN3d2RRWEJ3YkdVZ1EyVnlkR2xtYVdOaGRHbHZiaUJCZFhSb2IzSnBkSGt4RXpBUkJnTlZCQW9NQ2tGd2NHeGxJRWx1WXk0eEN6QUpCZ05WQkFZVEFsVlRBZ2dYZ0tnMFptVHF5ekFOQmdsZ2hrZ0JaUU1FQWdFRkFLQ0I2VEFZQmdrcWhraUc5dzBCQ1FNeEN3WUpLb1pJaHZjTkFRY0JNQndHQ1NxR1NJYjNEUUVKQlRFUEZ3MHlOVEEyTVRBd09ETXpNRFJhTUNvR0NTcUdTSWIzRFFFSk5ERWRNQnN3RFFZSllJWklBV1VEQkFJQkJRQ2hDZ1lJS29aSXpqMEVBd0l3THdZSktvWklodmNOQVFrRU1TSUVJUEdmeE5DVVJMakIyYlRkYStqcThpL2x4UllFYWQvbHFBbDV0d1ZMb2hrVE1GSUdDU3FHU0liM0RRRUpEekZGTUVNd0NnWUlLb1pJaHZjTkF3Y3dEZ1lJS29aSWh2Y05Bd0lDQWdDQU1BMEdDQ3FHU0liM0RRTUNBZ0ZBTUFjR0JTc09Bd0lITUEwR0NDcUdTSWIzRFFNQ0FnRW9NQW9HQ0NxR1NNNDlCQU1DQkVZd1JBSWdZMi95dnpzbzg1NklTNnptQ0N5Q3J6ZSt3OWtTeHBZR0l5Yk1pcHNaSzJjQ0lHSU5OMUgwaWxSUzhTRzNIWUhsSXg5ZUsvRGR1OUREaHlMb2ZuK1lsRS84PC9kYXRhPgoJCQkJCQkJCQoJPGtleT5FbnRpdGxlbWVudHM8L2tleT4KCTxkaWN0PgoJCTxrZXk+YmV0YS1yZXBvcnRzLWFjdGl2ZTwva2V5PgoJCTx0cnVlLz4KCQkJCQoJCQkJPGtleT5hcHBsaWNhdGlvbi1pZGVudGlmaWVyPC9rZXk+CgkJPHN0cmluZz5RTDc2WFlINzNQLm9yZy5yZWFjdGpzLm5hdGl2ZS5leGFtcGxlLnRlc3RhcHAudHVydGxldjI8L3N0cmluZz4KCQkJCQoJCQkJPGtleT5rZXljaGFpbi1hY2Nlc3MtZ3JvdXBzPC9rZXk+CgkJPGFycmF5PgoJCQkJPHN0cmluZz5RTDc2WFlINzNQLio8L3N0cmluZz4KCQkJCTxzdHJpbmc+Y29tLmFwcGxlLnRva2VuPC9zdHJpbmc+CgkJPC9hcnJheT4KCQkJCQoJCQkJPGtleT5nZXQtdGFzay1hbGxvdzwva2V5PgoJCTxmYWxzZS8+CgkJCQkKCQkJCTxrZXk+Y29tLmFwcGxlLmRldmVsb3Blci50ZWFtLWlkZW50aWZpZXI8L2tleT4KCQk8c3RyaW5nPlFMNzZYWUg3M1A8L3N0cmluZz4KCgk8L2RpY3Q+Cgk8a2V5PkV4cGlyYXRpb25EYXRlPC9rZXk+Cgk8ZGF0ZT4yMDI2LTA2LTEwVDA4OjIyOjQ5WjwvZGF0ZT4KCTxrZXk+TmFtZTwva2V5PgoJPHN0cmluZz4qW2V4cG9dIG9yZy5yZWFjdGpzLm5hdGl2ZS5leGFtcGxlLnRlc3RhcHAudHVydGxldjIgQXBwU3RvcmUgMjAyNC0wNi0wNFQxODoxMToyNi42MDJaPC9zdHJpbmc+Cgk8a2V5PlRlYW1JZGVudGlmaWVyPC9rZXk+Cgk8YXJyYXk+CgkJPHN0cmluZz5RTDc2WFlINzNQPC9zdHJpbmc+Cgk8L2FycmF5PgoJPGtleT5UZWFtTmFtZTwva2V5PgoJPHN0cmluZz5BbGljamEgV2FyY2hhxYI8L3N0cmluZz4KCTxrZXk+VGltZVRvTGl2ZTwva2V5PgoJPGludGVnZXI+MzY0PC9pbnRlZ2VyPgoJPGtleT5VVUlEPC9rZXk+Cgk8c3RyaW5nPmRjYmJjYTAwLTliZWMtNDRkNC04M2RlLTJiYWMzYTQ2ZWE0Yjwvc3RyaW5nPgoJPGtleT5WZXJzaW9uPC9rZXk+Cgk8aW50ZWdlcj4xPC9pbnRlZ2VyPgo8L2RpY3Q+CjwvcGxpc3Q+oIINPzCCBDQwggMcoAMCAQICCD1Z+Dfq0difMA0GCSqGSIb3DQEBCwUAMHMxLTArBgNVBAMMJEFwcGxlIGlQaG9uZSBDZXJ0aWZpY2F0aW9uIEF1dGhvcml0eTEgMB4GA1UECwwXQ2VydGlmaWNhdGlvbiBBdXRob3JpdHkxEzARBgNVBAoMCkFwcGxlIEluYy4xCzAJBgNVBAYTAlVTMB4XDTI0MTIxNjE5MjEwMVoXDTI5MTIxMTE4MTM1OVowWTE1MDMGA1UEAwwsQXBwbGUgaVBob25lIE9TIFByb3Zpc2lvbmluZyBQcm9maWxlIFNpZ25pbmcxEzARBgNVBAoMCkFwcGxlIEluYy4xCzAJBgNVBAYTAlVTMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA0JMxq/hTHt+tHi9k98lN15a+X0s9eWRcQ+4pw0DS+i8coLa8EXPv8CKw3975+c5V4/VCAbUjZUESO/d8Rn2JE0WVROyWlWJml5ADANngrsXZsZwgLZbghe1va9NS04M81AEjJenlxtQR2HmqCMLdFVj174qTSw1L7g22h5N1ERBVywx4B9s3cEY7l/rE63gp4PTseONh2kBXgAe7iJylx0ltyCbTlR9NIaKNaHODHuZZWoWWVVSwlS4l3HcNYYeBjYmkA3s8AHcsiWpZ02RE2XSlW4qxUmFfbgQw1PPiEDQMlYxL4XvWK4WD8ic5jXAII+IUiP+T9C1tTCbiizeGLQIDAQABo4HlMIHiMAwGA1UdEwEB/wQCMAAwHwYDVR0jBBgwFoAUb/GVGGJc4Mjxxe1sGMng02RSmCAwQAYIKwYBBQUHAQEENDAyMDAGCCsGAQUFBzABhiRodHRwOi8vb2NzcC5hcHBsZS5jb20vb2NzcDAzLWFpcGNhMDcwLwYDVR0fBCgwJjAkoCKgIIYeaHR0cDovL2NybC5hcHBsZS5jb20vYWlwY2EuY3JsMB0GA1UdDgQWBBS8tcXpvfzL0J7clLAe+CGUXP8JLjAOBgNVHQ8BAf8EBAMCB4AwDwYJKoZIhvdjZAY6BAIFADANBgkqhkiG9w0BAQsFAAOCAQEAMjTC6XeqX+DGyqLzd1nnime48VHz+7D2l7+xEaPhoTy7I5LpEpOicR288Zpxb6Q8bzaStuBHgqKT4+e4j7vfJURiAs/NvPf7jYoJcHhlwhlNJctyYiHkqWj5EJoueg8ovqYDBtFVYR+vfPiU1HEO4tMlVIvOrdVoB1u9LXvHYkV5uamHTPgYO4CuEEtx2Hgr5gqvmufZTczqW7ejl1Vr7A2geMfAsM/L3BBMMJITcZTWr+DsyenJm84lMu4RDSEXJIvxlS4iYkZFf+Db1xZUwbk+09qKuWX2tLQjf3hd2XJ2ZGOnCFXH2beU2yO/85On1AREQw3K4layK5QopD1yhzCCBEQwggMsoAMCAQICCFxjyuRKN1PJMA0GCSqGSIb3DQEBCwUAMGIxCzAJBgNVBAYTAlVTMRMwEQYDVQQKEwpBcHBsZSBJbmMuMSYwJAYDVQQLEx1BcHBsZSBDZXJ0aWZpY2F0aW9uIEF1dGhvcml0eTEWMBQGA1UEAxMNQXBwbGUgUm9vdCBDQTAeFw0xNzA1MTAyMTI3MzBaFw0zMDEyMzEwMDAwMDBaMHMxLTArBgNVBAMMJEFwcGxlIGlQaG9uZSBDZXJ0aWZpY2F0aW9uIEF1dGhvcml0eTEgMB4GA1UECwwXQ2VydGlmaWNhdGlvbiBBdXRob3JpdHkxEzARBgNVBAoMCkFwcGxlIEluYy4xCzAJBgNVBAYTAlVTMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAyUVqAQ8+gwSGx/y/3F7wHoHuFzBzYyYu3j16JM2TPk85R7p1vvPA0vFZoqsf/gqGPNktmgfyDmu5KZEaXyIKi/FyWAWuTEtExXmngDywiOCMDCeEXRnlhxk2y+PFdrew9EFyUfQFXINLom2mUbjxJt97Xq1lDMaymFGMu30bTMFOyAjH0u1kC7TdG41PQH0bj0iWklvz0Jh+2bykGQ6ZYbtBXQHMW3d6fSTQ3NNT/8PcxZQstlpNjhgjOb3ZxlI+0fL0JYqhKof92AxGKVH/7RdsiSVrh7+KaRSfd5/DFbdos4hFvYTmBgJBZA+tKii4FcngrKeKunIENLJ4jPiyhQIDAQABo4HsMIHpMA8GA1UdEwEB/wQFMAMBAf8wHwYDVR0jBBgwFoAUK9BpR5R2Cf70a40uQKb3R01/CF4wRAYIKwYBBQUHAQEEODA2MDQGCCsGAQUFBzABhihodHRwOi8vb2NzcC5hcHBsZS5jb20vb2NzcDAzLWFwcGxlcm9vdGNhMC4GA1UdHwQnMCUwI6AhoB+GHWh0dHA6Ly9jcmwuYXBwbGUuY29tL3Jvb3QuY3JsMB0GA1UdDgQWBBRv8ZUYYlzgyPHF7WwYyeDTZFKYIDAOBgNVHQ8BAf8EBAMCAQYwEAYKKoZIhvdjZAYCEgQCBQAwDQYJKoZIhvcNAQELBQADggEBADrPrJiNvpIgIQmtlfOxXCH6Ni1XIER0c2SSCLOWrPdtl/pbNDgnzxJG0zwR8AfJmZCx0egRCaXjpWtsYwg/niX61ZmcTOblzo6yTWjsi6ujok+KERU+3BQrHMZEtm9nxVtPlSkth1w/3IMed0/t2lSnLecTgcFjxFQLG0sKaigiCNQ3knx/Zyhfrz0/t6xZHTg0ZFruM0oZQkQpxMoYa+HBUy0t9E3CFfYzMhh48SZvik3rlEyj6P8PswOLZdrrLthlUJ/cn4rfMaiEVNxSUkHSshMdMUZHiF8+7sPyjCMEleusij6CbAafLuOLQ5piWzQN9JnPLO66coYZI6X8jrUwggS7MIIDo6ADAgECAgECMA0GCSqGSIb3DQEBBQUAMGIxCzAJBgNVBAYTAlVTMRMwEQYDVQQKEwpBcHBsZSBJbmMuMSYwJAYDVQQLEx1BcHBsZSBDZXJ0aWZpY2F0aW9uIEF1dGhvcml0eTEWMBQGA1UEAxMNQXBwbGUgUm9vdCBDQTAeFw0wNjA0MjUyMTQwMzZaFw0zNTAyMDkyMTQwMzZaMGIxCzAJBgNVBAYTAlVTMRMwEQYDVQQKEwpBcHBsZSBJbmMuMSYwJAYDVQQLEx1BcHBsZSBDZXJ0aWZpY2F0aW9uIEF1dGhvcml0eTEWMBQGA1UEAxMNQXBwbGUgUm9vdCBDQTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAOSRqQkfkdseR1DrBe1eeYQt6zaiV0xV7IsZid75S2z1B6siMALoGD74UAnTf0GomPnRymacJGsR0KO75Bsqwx+VnnoMpEeLW9QWNzPLxA9NzhRp0ckZcvVdDtV/X5vyJQO6VY9NXQ3xZDUjFUsVWR2zlPf2nJ7PULrBWFBnjwi0IPfLrCwgb3C2PwEwjLdDzw+dPfMrSSgayP7OtbkO2V4c1ss9tTqt9A8OAJILsSEWLnTVPA3bYharo3GSR1NVwa8vQbP4++NwzeajTEV+H0xrUJZBicR0YgsQg0GHM4qBsTBY7FoEMoxos48d3mVz/2deZbxJ2HafMxRloXeUyS0CAwEAAaOCAXowggF2MA4GA1UdDwEB/wQEAwIBBjAPBgNVHRMBAf8EBTADAQH/MB0GA1UdDgQWBBQr0GlHlHYJ/vRrjS5ApvdHTX8IXjAfBgNVHSMEGDAWgBQr0GlHlHYJ/vRrjS5ApvdHTX8IXjCCAREGA1UdIASCAQgwggEEMIIBAAYJKoZIhvdjZAUBMIHyMCoGCCsGAQUFBwIBFh5odHRwczovL3d3dy5hcHBsZS5jb20vYXBwbGVjYS8wgcMGCCsGAQUFBwICMIG2GoGzUmVsaWFuY2Ugb24gdGhpcyBjZXJ0aWZpY2F0ZSBieSBhbnkgcGFydHkgYXNzdW1lcyBhY2NlcHRhbmNlIG9mIHRoZSB0aGVuIGFwcGxpY2FibGUgc3RhbmRhcmQgdGVybXMgYW5kIGNvbmRpdGlvbnMgb2YgdXNlLCBjZXJ0aWZpY2F0ZSBwb2xpY3kgYW5kIGNlcnRpZmljYXRpb24gcHJhY3RpY2Ugc3RhdGVtZW50cy4wDQYJKoZIhvcNAQEFBQADggEBAFw2mUwteLftjJvc83eb8nbSdzBPwR+Fg4UbmT1HN/Kpm0COLNSxkBLYvvRzm+7SZA/LeU802KI++Xj/a8gH7H05g4tTINM4xLG/mk8Ka/8r/FmnBQl8F0BWER5007eLIztHo9VvJOLr0bdw3w9F4SfK8W147ee1Fxeo3H4iNcol1dkP1mvUoiQjEfehrI9zgWDGG1sJL5Ky+ERI8GA4nhX1PSZnIIozavcNgs/e66Mv+VNqW2TAYzN39zoHLFbr2g8hDtq6cxlPtdk2f8GHVdmnmbkyQvvY1XGefqFStxu9k0IkEirHDx22TZxeY8hLgBdQqorV2uT80AkHN7B1dSExggKFMIICgQIBATB/MHMxLTArBgNVBAMMJEFwcGxlIGlQaG9uZSBDZXJ0aWZpY2F0aW9uIEF1dGhvcml0eTEgMB4GA1UECwwXQ2VydGlmaWNhdGlvbiBBdXRob3JpdHkxEzARBgNVBAoMCkFwcGxlIEluYy4xCzAJBgNVBAYTAlVTAgg9Wfg36tHYnzAJBgUrDgMCGgUAoIHcMBgGCSqGSIb3DQEJAzELBgkqhkiG9w0BBwEwHAYJKoZIhvcNAQkFMQ8XDTI1MDYxMDA4MzMwNFowIwYJKoZIhvcNAQkEMRYEFJxXrTwrpTx16wHCr42McagHLN5aMCkGCSqGSIb3DQEJNDEcMBowCQYFKw4DAhoFAKENBgkqhkiG9w0BAQEFADBSBgkqhkiG9w0BCQ8xRTBDMAoGCCqGSIb3DQMHMA4GCCqGSIb3DQMCAgIAgDANBggqhkiG9w0DAgIBQDAHBgUrDgMCBzANBggqhkiG9w0DAgIBKDANBgkqhkiG9w0BAQEFAASCAQAFR1Ds7kzMubi6IHqjGGbixW5uNNrrCW0bpiYJy2PyfTTzALY2hLnwCuqryHjINbc6UNlaCl5Cy+G3aYlFVVz8HH+UHj3BHf8VDhTuylolMYpaDeKmcLuOPEzDBoxBwEeVNtC1VMM0Qhca0Gn+0oSQpTh+Xin4RMLttVw6Iy7RUE627lJ5pYNw63lMXF8MFqdjKDz/le2nmTGt+wox9WPsVpt8W2ezORm2lgARvep61rbOoCiCI92NjLLC7LnQR9eWvK5BgHueP0DbAuPq26/8kkIt4YzLPBerEyFfOhcY84fiI08xgcRjhbqC00ZNmE3WEw8YVV75YL0o1CFdp5Ga`, + certFingerprint: '61F85A5106372ABAD1E69EF2C115712CC1C1702C', +}; diff --git a/packages/build-tools/src/steps/utils/ios/credentials/__tests__/provisioningProfile.test.ios.ts b/packages/build-tools/src/steps/utils/ios/credentials/__tests__/provisioningProfile.test.ios.ts new file mode 100644 index 0000000000..1ec242b352 --- /dev/null +++ b/packages/build-tools/src/steps/utils/ios/credentials/__tests__/provisioningProfile.test.ios.ts @@ -0,0 +1,64 @@ +import { createLogger } from '@expo/logger'; + +import Keychain from '../keychain'; +import ProvisioningProfile from '../provisioningProfile'; + +import { provisioningProfile } from './fixtures'; + +const mockLogger = createLogger({ name: 'mock-logger' }); + +jest.setTimeout(60 * 1000); + +// Those are in fact "real" tests in that they really execute the `fastlane` commands. +// We need the JS code to modify the file system, so we need to mock it. +jest.unmock('fs'); + +describe('ProvisioningProfile class', () => { + describe('verifyCertificate method', () => { + let keychain: Keychain; + + beforeAll(async () => { + keychain = new Keychain(); + await keychain.create(mockLogger); + }); + + afterAll(async () => { + await keychain.destroy(mockLogger); + }); + + it("shouldn't throw any error if the provisioning profile and distribution certificate match", async () => { + const pp = new ProvisioningProfile( + Buffer.from(provisioningProfile.dataBase64, 'base64'), + keychain.data.path, + 'testapp', + 'Abc 123' + ); + try { + await pp.init(mockLogger); + expect(() => { + pp.verifyCertificate(provisioningProfile.certFingerprint); + }).not.toThrow(); + } finally { + await pp.destroy(mockLogger); + } + }); + + it("should throw an error if the provisioning profile and distribution certificate don't match", async () => { + const pp = new ProvisioningProfile( + Buffer.from(provisioningProfile.dataBase64, 'base64'), + keychain.data.path, + 'testapp', + 'Abc 123' + ); + + try { + await pp.init(mockLogger); + expect(() => { + pp.verifyCertificate('2137'); + }).toThrowError(/don't match/); + } finally { + await pp.destroy(mockLogger); + } + }); + }); +}); diff --git a/packages/build-tools/src/steps/utils/ios/credentials/credentials.ts b/packages/build-tools/src/steps/utils/ios/credentials/credentials.ts new file mode 100644 index 0000000000..8bf431a8de --- /dev/null +++ b/packages/build-tools/src/steps/utils/ios/credentials/credentials.ts @@ -0,0 +1,15 @@ +import { Ios } from '@expo/eas-build-job'; +import Joi from 'joi'; + +export const TargetCredentialsSchema = Joi.object().keys({ + provisioningProfileBase64: Joi.string().required(), + distributionCertificate: Joi.object({ + dataBase64: Joi.string().required(), + password: Joi.string().allow('').required(), + }).required(), +}); + +export const IosBuildCredentialsSchema = Joi.object().pattern( + Joi.string().required(), + TargetCredentialsSchema +); diff --git a/packages/build-tools/src/steps/utils/ios/credentials/distributionCertificate.ts b/packages/build-tools/src/steps/utils/ios/credentials/distributionCertificate.ts new file mode 100644 index 0000000000..e49c1600c9 --- /dev/null +++ b/packages/build-tools/src/steps/utils/ios/credentials/distributionCertificate.ts @@ -0,0 +1,46 @@ +import { Ios } from '@expo/eas-build-job'; +import forge from 'node-forge'; + +export function getFingerprint({ dataBase64, password }: Ios.DistributionCertificate): string { + const certData = getCertData(dataBase64, password); + const certAsn1 = forge.pki.certificateToAsn1(certData); + const certDer = forge.asn1.toDer(certAsn1).getBytes(); + const fingerprint = forge.md.sha1.create().update(certDer).digest().toHex().toUpperCase(); + return fingerprint; +} + +export function getCommonName({ dataBase64, password }: Ios.DistributionCertificate): string { + const certData = getCertData(dataBase64, password); + const { attributes } = certData.subject; + const commonNameAttribute = attributes.find( + ({ name }: { name?: string }) => name === 'commonName' + ); + return Buffer.from(commonNameAttribute.value, 'ascii').toString(); +} + +function getCertData(certificateBase64: string, password: string): any { + const p12Der = forge.util.decode64(certificateBase64); + const p12Asn1 = forge.asn1.fromDer(p12Der); + let p12: forge.pkcs12.Pkcs12Pfx; + try { + if (password) { + p12 = forge.pkcs12.pkcs12FromAsn1(p12Asn1, password); + } else { + p12 = forge.pkcs12.pkcs12FromAsn1(p12Asn1); + } + } catch (_error: any) { + const error: Error = _error; + if (/Invalid password/.exec(error.message)) { + throw new Error('Provided password for the distribution certificate is probably invalid'); + } else { + throw error; + } + } + + const certBagType = forge.pki.oids.certBag; + const certData = p12.getBags({ bagType: certBagType })?.[certBagType]?.[0]?.cert; + if (!certData) { + throw new Error("getCertData: couldn't find cert bag"); + } + return certData; +} diff --git a/packages/build-tools/src/steps/utils/ios/credentials/keychain.ts b/packages/build-tools/src/steps/utils/ios/credentials/keychain.ts new file mode 100644 index 0000000000..78904529e5 --- /dev/null +++ b/packages/build-tools/src/steps/utils/ios/credentials/keychain.ts @@ -0,0 +1,113 @@ +import os from 'os'; +import path from 'path'; + +import spawn from '@expo/turtle-spawn'; +import { v4 as uuid } from 'uuid'; +import { bunyan } from '@expo/logger'; + +import { runFastlane } from '../fastlane'; + +export default class Keychain { + private readonly keychainPath: string; + private readonly keychainPassword: string; + private created = false; + private destroyed = false; + + constructor() { + this.keychainPath = path.join(os.tmpdir(), `eas-build-${uuid()}.keychain`); + this.keychainPassword = uuid(); + } + + get data(): { path: string; password: string } { + return { + path: this.keychainPath, + password: this.keychainPassword, + }; + } + + public async create(logger: bunyan): Promise { + logger.debug(`Creating keychain - ${this.keychainPath}`); + await runFastlane([ + 'run', + 'create_keychain', + `path:${this.keychainPath}`, + `password:${this.keychainPassword}`, + 'unlock:true', + 'timeout:360000', + ]); + this.created = true; + } + + public async importCertificate( + logger: bunyan, + certPath: string, + certPassword: string + ): Promise { + if (!this.created) { + throw new Error('You must create a keychain first.'); + } + + logger.debug(`Importing certificate ${certPath} into keychain ${this.keychainPath}`); + await runFastlane([ + 'run', + 'import_certificate', + `certificate_path:${certPath}`, + `certificate_password:${certPassword}`, + `keychain_path:${this.keychainPath}`, + `keychain_password:${this.keychainPassword}`, + ]); + } + + public async ensureCertificateImported(teamId: string, fingerprint: string): Promise { + const identities = await this.findIdentitiesByTeamId(teamId); + if (!identities.includes(fingerprint)) { + throw new Error( + `Distribution certificate with fingerprint ${fingerprint} hasn't been imported successfully` + ); + } + } + + public async destroy(logger: bunyan, keychainPath?: string): Promise { + if (!keychainPath && !this.created) { + logger.warn("There is nothing to destroy, a keychain hasn't been created yet."); + return; + } + if (this.destroyed) { + logger.warn('The keychain has been already destroyed'); + return; + } + const keychainToDeletePath = keychainPath ?? this.keychainPath; + logger.info(`Destroying keychain - ${keychainToDeletePath}`); + try { + await runFastlane(['run', 'delete_keychain', `keychain_path:${keychainToDeletePath}`]); + this.destroyed = true; + } catch (err) { + logger.error({ err }, 'Failed to delete the keychain\n'); + throw err; + } + } + + public async cleanUpKeychains(logger: bunyan): Promise { + const { stdout } = await spawn('security', ['list-keychains'], { stdio: 'pipe' }); + const keychainList = (/"(.*)"/g.exec(stdout) ?? ([] as string[])).map((i) => + i.slice(1, i.length - 1) + ); + const turtleKeychainList = keychainList.filter((keychain) => + /eas-build-[\w-]+\.keychain$/.exec(keychain) + ); + for (const turtleKeychainPath of turtleKeychainList) { + await this.destroy(logger, turtleKeychainPath); + } + } + + private async findIdentitiesByTeamId(teamId: string): Promise { + const { output } = await spawn( + 'security', + ['find-identity', '-v', '-s', `(${teamId})`, this.keychainPath], + { + stdio: 'pipe', + } + ); + return output.join(''); + } +} diff --git a/packages/build-tools/src/steps/utils/ios/credentials/manager.ts b/packages/build-tools/src/steps/utils/ios/credentials/manager.ts new file mode 100644 index 0000000000..4b57db764b --- /dev/null +++ b/packages/build-tools/src/steps/utils/ios/credentials/manager.ts @@ -0,0 +1,149 @@ +import assert from 'assert'; +import os from 'os'; +import path from 'path'; + +import { Ios } from '@expo/eas-build-job'; +import fs from 'fs-extra'; +import { orderBy } from 'lodash'; +import { v4 as uuid } from 'uuid'; +import { bunyan } from '@expo/logger'; + +import * as distributionCertificateUtils from './distributionCertificate'; +import Keychain from './keychain'; +import ProvisioningProfile, { + DistributionType, + ProvisioningProfileData, +} from './provisioningProfile'; + +export interface Credentials { + applicationTargetProvisioningProfile: ProvisioningProfile; + keychainPath: string; + targetProvisioningProfiles: TargetProvisioningProfiles; + distributionType: DistributionType; + teamId: string; +} + +export type TargetProvisioningProfiles = Record; + +export default class IosCredentialsManager { + private keychain?: Keychain; + private readonly provisioningProfiles: ProvisioningProfile[] = []; + private cleanedUp = false; + + constructor(private readonly buildCredentials: Ios.BuildCredentials) {} + + public async prepare(logger: bunyan): Promise { + logger.info('Preparing credentials'); + + logger.info('Creating keychain'); + this.keychain = new Keychain(); + await this.keychain.create(logger); + + const targets = Object.keys(this.buildCredentials); + const targetProvisioningProfiles: TargetProvisioningProfiles = {}; + for (const target of targets) { + const provisioningProfile = await this.prepareTargetCredentials( + logger, + target, + this.buildCredentials[target] + ); + this.provisioningProfiles.push(provisioningProfile); + targetProvisioningProfiles[target] = provisioningProfile.data; + } + + const applicationTargetProvisioningProfile = this.getApplicationTargetProvisioningProfile(); + + // TODO: ensure that all dist types and team ids in the array are the same + const { distributionType, teamId } = applicationTargetProvisioningProfile.data; + + return { + applicationTargetProvisioningProfile, + keychainPath: this.keychain.data.path, + targetProvisioningProfiles, + distributionType, + teamId, + }; + } + + public async cleanUp(logger: bunyan): Promise { + if (this.cleanedUp || (!this.keychain && this.provisioningProfiles.length === 0)) { + return; + } + + if (this.keychain) { + await this.keychain.destroy(logger); + } + if (this.provisioningProfiles) { + for (const provisioningProfile of this.provisioningProfiles) { + await provisioningProfile.destroy(logger); + } + } + this.cleanedUp = true; + } + + private async prepareTargetCredentials( + logger: bunyan, + target: string, + targetCredentials: Ios.TargetCredentials + ): Promise { + try { + assert(this.keychain, 'Keychain should be initialized'); + + logger.info(`Preparing credentials for target '${target}'`); + const distCertPath = path.join(os.tmpdir(), `${uuid()}.p12`); + + logger.info('Getting distribution certificate fingerprint and common name'); + const certificateFingerprint = distributionCertificateUtils.getFingerprint( + targetCredentials.distributionCertificate + ); + const certificateCommonName = distributionCertificateUtils.getCommonName( + targetCredentials.distributionCertificate + ); + logger.info( + `Fingerprint = "${certificateFingerprint}", common name = ${certificateCommonName}` + ); + + logger.info(`Writing distribution certificate to ${distCertPath}`); + await fs.writeFile( + distCertPath, + new Uint8Array(Buffer.from(targetCredentials.distributionCertificate.dataBase64, 'base64')) + ); + + logger.info('Importing distribution certificate into the keychain'); + await this.keychain.importCertificate( + logger, + distCertPath, + targetCredentials.distributionCertificate.password + ); + + logger.info('Initializing provisioning profile'); + const provisioningProfile = new ProvisioningProfile( + Buffer.from(targetCredentials.provisioningProfileBase64, 'base64'), + this.keychain.data.path, + target, + certificateCommonName + ); + await provisioningProfile.init(logger); + + logger.info('Validating whether distribution certificate has been imported successfully'); + await this.keychain.ensureCertificateImported( + provisioningProfile.data.teamId, + certificateFingerprint + ); + + logger.info('Verifying whether the distribution certificate and provisioning profile match'); + provisioningProfile.verifyCertificate(certificateFingerprint); + + return provisioningProfile; + } catch (err) { + await this.cleanUp(logger); + throw err; + } + } + + private getApplicationTargetProvisioningProfile(): ProvisioningProfile { + // sorting works because bundle ids share common prefix + const sorted = orderBy(this.provisioningProfiles, 'data.bundleIdentifier', 'asc'); + return sorted[0]; + } +} diff --git a/packages/build-tools/src/steps/utils/ios/credentials/provisioningProfile.ts b/packages/build-tools/src/steps/utils/ios/credentials/provisioningProfile.ts new file mode 100644 index 0000000000..4802925356 --- /dev/null +++ b/packages/build-tools/src/steps/utils/ios/credentials/provisioningProfile.ts @@ -0,0 +1,144 @@ +import crypto from 'crypto'; +import os from 'os'; +import path from 'path'; + +import { errors } from '@expo/eas-build-job'; +import spawn from '@expo/turtle-spawn'; +import fs from 'fs-extra'; +import plist from 'plist'; +import { v4 as uuid } from 'uuid'; +import { bunyan } from '@expo/logger'; + +export interface ProvisioningProfileData { + path: string; + target: string; + bundleIdentifier: string; + teamId: string; + uuid: string; + name: string; + developerCertificate: Buffer; + certificateCommonName: string; + distributionType: DistributionType; +} + +export enum DistributionType { + AD_HOC = 'ad-hoc', + APP_STORE = 'app-store', + ENTERPRISE = 'enterprise', +} + +const PROVISIONING_PROFILES_DIRECTORY = path.join( + os.homedir(), + 'Library/MobileDevice/Provisioning Profiles' +); + +export default class ProvisioningProfile { + get data(): ProvisioningProfileData { + if (!this.profileData) { + throw new Error('You must init the profile first!'); + } else { + return this.profileData; + } + } + + private readonly profilePath: string; + private profileData?: ProvisioningProfileData; + + constructor( + private readonly profile: Buffer, + private readonly keychainPath: string, + private readonly target: string, + private readonly certificateCommonName: string + ) { + this.profilePath = path.join(PROVISIONING_PROFILES_DIRECTORY, `${uuid()}.mobileprovision`); + } + + public async init(logger: bunyan): Promise { + logger.debug(`Making sure ${PROVISIONING_PROFILES_DIRECTORY} exits`); + await fs.ensureDir(PROVISIONING_PROFILES_DIRECTORY); + + logger.debug(`Writing provisioning profile to ${this.profilePath}`); + await fs.writeFile(this.profilePath, new Uint8Array(this.profile)); + + logger.debug('Loading provisioning profile'); + await this.load(); + } + + public async destroy(logger: bunyan): Promise { + if (!this.profilePath) { + logger.warn("There is nothing to destroy, a provisioning profile hasn't been created yet."); + return; + } + logger.info('Removing provisioning profile'); + await fs.remove(this.profilePath); + } + + public verifyCertificate(fingerprint: string): void { + const devCertFingerprint = this.genDerCertFingerprint(); + if (devCertFingerprint !== fingerprint) { + throw new errors.CredentialsDistCertMismatchError( + `Provisioning profile and distribution certificate don't match. +Profile's certificate fingerprint = ${devCertFingerprint}, distribution certificate fingerprint = ${fingerprint}` + ); + } + } + + private async load(): Promise { + let result; + try { + result = await spawn( + 'security', + ['cms', '-D', '-k', this.keychainPath, '-i', this.profilePath], + { + stdio: 'pipe', + } + ); + } catch (err: any) { + throw new Error(err.stderr.trim()); + } + const { output } = result; + + const plistRaw = output.join(''); + let plistData; + try { + plistData = plist.parse(plistRaw) as plist.PlistObject; + } catch (error: any) { + throw new Error(`Error when parsing plist: ${error.message}`); + } + + const applicationIdentifier = (plistData.Entitlements as plist.PlistObject)[ + 'application-identifier' + ] as string; + const bundleIdentifier = applicationIdentifier.replace(/^.+?\./, ''); + + this.profileData = { + path: this.profilePath, + target: this.target, + bundleIdentifier, + teamId: (plistData.TeamIdentifier as string[])[0], + uuid: plistData.UUID as string, + name: plistData.Name as string, + developerCertificate: Buffer.from((plistData.DeveloperCertificates as string[])[0], 'base64'), + certificateCommonName: this.certificateCommonName, + distributionType: this.resolveDistributionType(plistData), + }; + } + + private resolveDistributionType(plistData: plist.PlistObject): DistributionType { + if (plistData.ProvisionsAllDevices) { + return DistributionType.ENTERPRISE; + } else if (plistData.ProvisionedDevices) { + return DistributionType.AD_HOC; + } else { + return DistributionType.APP_STORE; + } + } + + private genDerCertFingerprint(): string { + return crypto + .createHash('sha1') + .update(new Uint8Array(this.data.developerCertificate)) + .digest('hex') + .toUpperCase(); + } +} diff --git a/packages/build-tools/src/steps/utils/ios/expoUpdates.ts b/packages/build-tools/src/steps/utils/ios/expoUpdates.ts new file mode 100644 index 0000000000..37e6e97e54 --- /dev/null +++ b/packages/build-tools/src/steps/utils/ios/expoUpdates.ts @@ -0,0 +1,74 @@ +import { IOSConfig } from '@expo/config-plugins'; +import fs from 'fs-extra'; +import plist from '@expo/plist'; + +export enum IosMetadataName { + UPDATES_CONFIGURATION_REQUEST_HEADERS_KEY = 'EXUpdatesRequestHeaders', + RELEASE_CHANNEL = 'EXUpdatesReleaseChannel', + RUNTIME_VERSION = 'EXUpdatesRuntimeVersion', +} + +export async function iosSetChannelNativelyAsync( + channel: string, + workingDirectory: string +): Promise { + const expoPlistPath = IOSConfig.Paths.getExpoPlistPath(workingDirectory); + + if (!(await fs.pathExists(expoPlistPath))) { + throw new Error(`${expoPlistPath} does not exist`); + } + + const expoPlistContents = await fs.readFile(expoPlistPath, 'utf8'); + const items: Record> = plist.parse(expoPlistContents); + items[IosMetadataName.UPDATES_CONFIGURATION_REQUEST_HEADERS_KEY] = { + ...((items[IosMetadataName.UPDATES_CONFIGURATION_REQUEST_HEADERS_KEY] as Record< + string, + string + >) ?? {}), + 'expo-channel-name': channel, + }; + const updatedExpoPlistContents = plist.build(items); + + await fs.writeFile(expoPlistPath, updatedExpoPlistContents); +} + +export async function iosSetRuntimeVersionNativelyAsync( + runtimeVersion: string, + workingDirectory: string +): Promise { + const expoPlistPath = IOSConfig.Paths.getExpoPlistPath(workingDirectory); + + if (!(await fs.pathExists(expoPlistPath))) { + throw new Error(`${expoPlistPath} does not exist`); + } + + const expoPlistContents = await fs.readFile(expoPlistPath, 'utf8'); + const items = plist.parse(expoPlistContents); + items[IosMetadataName.RUNTIME_VERSION] = runtimeVersion; + const updatedExpoPlistContents = plist.build(items); + + await fs.writeFile(expoPlistPath, updatedExpoPlistContents); +} + +export async function iosGetNativelyDefinedChannelAsync( + workingDirectory: string +): Promise { + const expoPlistPath = IOSConfig.Paths.getExpoPlistPath(workingDirectory); + + if (!(await fs.pathExists(expoPlistPath))) { + return null; + } + + const expoPlistContents = await fs.readFile(expoPlistPath, 'utf8'); + try { + const items: Record> = plist.parse(expoPlistContents); + const updatesRequestHeaders = (items[ + IosMetadataName.UPDATES_CONFIGURATION_REQUEST_HEADERS_KEY + ] ?? {}) as Record; + return updatesRequestHeaders['expo-channel-name'] ?? null; + } catch (err: any) { + throw new Error( + `Failed to parse ${IosMetadataName.UPDATES_CONFIGURATION_REQUEST_HEADERS_KEY} from Expo.plist: ${err.message}` + ); + } +} diff --git a/packages/build-tools/src/steps/utils/ios/fastlane.ts b/packages/build-tools/src/steps/utils/ios/fastlane.ts new file mode 100644 index 0000000000..c045b07b1d --- /dev/null +++ b/packages/build-tools/src/steps/utils/ios/fastlane.ts @@ -0,0 +1,61 @@ +import path from 'path'; + +import { bunyan } from '@expo/logger'; +import spawn, { SpawnResult } from '@expo/turtle-spawn'; +import { BuildStepEnv } from '@expo/steps'; + +import { COMMON_FASTLANE_ENV } from '../../../common/fastlane'; + +import { XcodeBuildLogger } from './xcpretty'; + +export async function runFastlaneGym({ + workingDir, + logger, + buildLogsDirectory, + env, + extraEnv, +}: { + workingDir: string; + logger: bunyan; + buildLogsDirectory: string; + env: BuildStepEnv; + extraEnv?: BuildStepEnv; +}): Promise { + const buildLogger = new XcodeBuildLogger(logger, workingDir); + void buildLogger.watchLogFiles(buildLogsDirectory); + try { + await runFastlane(['gym'], { + cwd: path.join(workingDir, 'ios'), + logger, + env, + extraEnv, + }); + } finally { + await buildLogger.flush(); + } +} + +export async function runFastlane( + fastlaneArgs: string[], + { + logger, + env, + cwd, + extraEnv, + }: { + logger?: bunyan; + env?: BuildStepEnv; + cwd?: string; + extraEnv?: BuildStepEnv; + } = {} +): Promise { + return await spawn('fastlane', fastlaneArgs, { + env: { + ...COMMON_FASTLANE_ENV, + ...(env ?? process.env), + ...extraEnv, + }, + logger, + cwd, + }); +} diff --git a/packages/build-tools/src/steps/utils/ios/resolve.ts b/packages/build-tools/src/steps/utils/ios/resolve.ts new file mode 100644 index 0000000000..5727507ec1 --- /dev/null +++ b/packages/build-tools/src/steps/utils/ios/resolve.ts @@ -0,0 +1,28 @@ +import assert from 'assert'; + +import { Ios } from '@expo/eas-build-job'; +import { IOSConfig } from '@expo/config-plugins'; + +export function resolveScheme(workingDir: string, job: Ios.Job, scheme?: string): string { + if (scheme) { + return scheme; + } + if (job.scheme) { + return job.scheme; + } + const schemes = IOSConfig.BuildScheme.getSchemesFromXcodeproj(workingDir); + assert(schemes.length === 1, 'Ejected project should have exactly one scheme'); + return schemes[0]; +} + +export function resolveBuildConfiguration(job: Ios.Job, buildConfiguration?: string): string { + if (buildConfiguration) { + return buildConfiguration; + } else if (job.buildConfiguration) { + return job.buildConfiguration; + } else if (job.developmentClient) { + return 'Debug'; + } else { + return 'Release'; + } +} diff --git a/packages/build-tools/src/steps/utils/ios/tvos.ts b/packages/build-tools/src/steps/utils/ios/tvos.ts new file mode 100644 index 0000000000..6a5069fc5a --- /dev/null +++ b/packages/build-tools/src/steps/utils/ios/tvos.ts @@ -0,0 +1,24 @@ +import { IOSConfig } from '@expo/config-plugins'; + +export async function isTVOS({ + scheme, + buildConfiguration, + workingDir, +}: { + scheme: string; + buildConfiguration: string; + workingDir: string; +}): Promise { + const project = IOSConfig.XcodeUtils.getPbxproj(workingDir); + + const targetName = await IOSConfig.BuildScheme.getApplicationTargetNameForSchemeAsync( + workingDir, + scheme + ); + + const xcBuildConfiguration = IOSConfig.Target.getXCBuildConfigurationFromPbxproj(project, { + targetName, + buildConfiguration, + }); + return xcBuildConfiguration?.buildSettings?.SDKROOT?.includes('appletv'); +} diff --git a/packages/build-tools/src/steps/utils/ios/xcpretty.ts b/packages/build-tools/src/steps/utils/ios/xcpretty.ts new file mode 100644 index 0000000000..e6b48c425f --- /dev/null +++ b/packages/build-tools/src/steps/utils/ios/xcpretty.ts @@ -0,0 +1,92 @@ +import assert from 'assert'; +import path from 'path'; + +import fs from 'fs-extra'; +import { bunyan } from '@expo/logger'; +import { ExpoRunFormatter } from '@expo/xcpretty'; +import spawnAsync, { SpawnPromise, SpawnResult } from '@expo/spawn-async'; +import fg from 'fast-glob'; + +const CHECK_FILE_INTERVAL_MS = 1000; + +export class XcodeBuildLogger { + private loggerError?: Error; + private flushing: boolean = false; + private logReaderPromise?: SpawnPromise; + private logsPath?: string; + + constructor( + private readonly logger: bunyan, + private readonly projectRoot: string + ) {} + + public async watchLogFiles(logsDirectory: string): Promise { + while (!this.flushing) { + const logsFilename = await this.getBuildLogFilename(logsDirectory); + if (logsFilename) { + this.logsPath = path.join(logsDirectory, logsFilename); + void this.startBuildLogger(this.logsPath); + return; + } + await new Promise((res) => setTimeout(res, CHECK_FILE_INTERVAL_MS)); + } + } + + public async flush(): Promise { + this.flushing = true; + if (this.loggerError) { + throw this.loggerError; + } + if (this.logReaderPromise) { + this.logReaderPromise.child.kill('SIGINT'); + try { + await this.logReaderPromise; + } catch {} + } + if (this.logsPath) { + await this.findBundlerErrors(this.logsPath); + } + } + private async getBuildLogFilename(logsDirectory: string): Promise { + const paths = await fg('*.log', { cwd: logsDirectory }); + return paths.length >= 1 ? paths[0] : undefined; + } + + private async startBuildLogger(logsPath: string): Promise { + try { + const formatter = ExpoRunFormatter.create(this.projectRoot, { + // TODO: Can provide xcode project name for better parsing + isDebug: false, + }); + this.logReaderPromise = spawnAsync('tail', ['-n', '+0', '-f', logsPath], { stdio: 'pipe' }); + assert(this.logReaderPromise.child.stdout, 'stdout is not available'); + this.logReaderPromise.child.stdout.on('data', (data: string) => { + const lines = formatter.pipe(data.toString()); + for (const line of lines) { + this.logger.info(line); + } + }); + await this.logReaderPromise; + + this.logger.info(formatter.getBuildSummary()); + } catch (err: any) { + if (!this.flushing) { + this.loggerError = err; + } + } + } + + private async findBundlerErrors(logsPath: string): Promise { + try { + const logFile = await fs.readFile(logsPath, 'utf-8'); + const match = logFile.match( + /Welcome to Metro!\s* Fast - Scalable - Integrated\s*([\s\S]*)Run CLI with --verbose flag for more details.\nCommand PhaseScriptExecution failed with a nonzero exit code/ + ); + if (match) { + this.logger.info(match[1]); + } + } catch (err) { + this.logger.error({ err }, 'Failed to read Xcode logs'); + } + } +} diff --git a/packages/build-tools/src/steps/utils/slackMessageDynamicFields.ts b/packages/build-tools/src/steps/utils/slackMessageDynamicFields.ts new file mode 100644 index 0000000000..67051cdced --- /dev/null +++ b/packages/build-tools/src/steps/utils/slackMessageDynamicFields.ts @@ -0,0 +1,10 @@ +export enum BuildStepOutputName { + STATUS_TEXT = 'status_text', + ERROR_TEXT = 'error_text', +} + +export enum BuildStatusText { + SUCCESS = 'SUCCESS', + ERROR = 'ERROR', + STARTED = 'STARTED', +} diff --git a/packages/build-tools/src/templates/EasBuildConfigureVersionGradle.ts b/packages/build-tools/src/templates/EasBuildConfigureVersionGradle.ts new file mode 100644 index 0000000000..ed7e8e1237 --- /dev/null +++ b/packages/build-tools/src/templates/EasBuildConfigureVersionGradle.ts @@ -0,0 +1,34 @@ +export const EasBuildConfigureVersionGradleTemplate = `// Build integration with EAS + +import java.nio.file.Paths + +def versionCodeVal = null +def versionNameVal = null +<% if (VERSION_CODE) { %> + versionCodeVal = "<%- VERSION_CODE %>" +<% } %> +<% if (VERSION_NAME) { %> + versionNameVal = "<%- VERSION_NAME %>" +<% } %> + +android { + defaultConfig { + if (versionCodeVal) { + versionCode = Integer.parseInt(versionCodeVal) + } + if (versionNameVal) { + versionName = versionNameVal + } + } + applicationVariants.all { variant -> + variant.outputs.each { output -> + if (versionCodeVal) { + output.versionCodeOverride = Integer.parseInt(versionCodeVal) + } + if (versionNameVal) { + output.versionNameOverride = versionNameVal + } + } + } +} +`; diff --git a/packages/build-tools/src/templates/EasBuildGradle.ts b/packages/build-tools/src/templates/EasBuildGradle.ts new file mode 100644 index 0000000000..fefd3502cb --- /dev/null +++ b/packages/build-tools/src/templates/EasBuildGradle.ts @@ -0,0 +1,54 @@ +export const EasBuildGradle = `// Build integration with EAS + +import java.nio.file.Paths + +def versionCodeFromEnv = System.getenv("EAS_BUILD_ANDROID_VERSION_CODE") +def versionNameFromEnv = System.getenv("EAS_BUILD_ANDROID_VERSION_NAME") + +android { + defaultConfig { + if (versionCodeFromEnv) { + versionCode = Integer.parseInt(versionCodeFromEnv) + } + if (versionNameFromEnv) { + versionName = versionNameFromEnv + } + } + applicationVariants.all { variant -> + variant.outputs.each { output -> + if (versionCodeFromEnv) { + output.versionCodeOverride = Integer.parseInt(versionCodeFromEnv) + } + if (versionNameFromEnv) { + output.versionNameOverride = versionNameFromEnv + } + } + } + signingConfigs { + release { + def credentialsJson = Paths.get(System.getenv("EAS_BUILD_WORKINGDIR")).resolve("credentials.json").toFile(); + def credentials = new groovy.json.JsonSlurper().parse(credentialsJson) + def keystorePath = Paths.get(credentials.android.keystore.keystorePath); + + storeFile keystorePath.toFile() + storePassword credentials.android.keystore.keystorePassword + keyAlias credentials.android.keystore.keyAlias + if (credentials.android.keystore.containsKey("keyPassword")) { + keyPassword credentials.android.keystore.keyPassword + } else { + // key password is required by Gradle, but PKCS keystores don't have one + // using the keystore password seems to satisfy the requirement + keyPassword credentials.android.keystore.keystorePassword + } + } + } + + buildTypes { + release { + signingConfig android.signingConfigs.release + } + debug { + signingConfig android.signingConfigs.release + } + } +}`; diff --git a/packages/build-tools/src/templates/EasBuildInjectAndroidCredentialsGradle.ts b/packages/build-tools/src/templates/EasBuildInjectAndroidCredentialsGradle.ts new file mode 100644 index 0000000000..600661ea21 --- /dev/null +++ b/packages/build-tools/src/templates/EasBuildInjectAndroidCredentialsGradle.ts @@ -0,0 +1,34 @@ +export const EasBuildInjectAndroidCredentialsGradle = `// Build integration with EAS + +import java.nio.file.Paths + + +android { + signingConfigs { + release { + def credentialsJson = Paths.get(System.getenv("EAS_BUILD_WORKINGDIR")).resolve("credentials.json").toFile(); + def credentials = new groovy.json.JsonSlurper().parse(credentialsJson) + def keystorePath = Paths.get(credentials.android.keystore.keystorePath); + + storeFile keystorePath.toFile() + storePassword credentials.android.keystore.keystorePassword + keyAlias credentials.android.keystore.keyAlias + if (credentials.android.keystore.containsKey("keyPassword")) { + keyPassword credentials.android.keystore.keyPassword + } else { + // key password is required by Gradle, but PKCS keystores don't have one + // using the keystore password seems to satisfy the requirement + keyPassword credentials.android.keystore.keystorePassword + } + } + } + + buildTypes { + release { + signingConfig android.signingConfigs.release + } + debug { + signingConfig android.signingConfigs.release + } + } +}`; diff --git a/packages/build-tools/src/templates/FastfileResign.ts b/packages/build-tools/src/templates/FastfileResign.ts new file mode 100644 index 0000000000..df75545991 --- /dev/null +++ b/packages/build-tools/src/templates/FastfileResign.ts @@ -0,0 +1,11 @@ +export const FastfileResignTemplate = `lane :do_resign do + resign( + ipa: "<%- IPA_PATH %>", + signing_identity: "<%- SIGNING_IDENTITY %>", + provisioning_profile: {<% _.forEach(PROFILES, function(profile) { %> + "<%- profile.BUNDLE_ID %>" => "<%- profile.PATH %>",<% }); %> + }, + keychain_path: "<%- KEYCHAIN_PATH %>" + ) +end +`; diff --git a/packages/build-tools/src/templates/GymfileArchive.ts b/packages/build-tools/src/templates/GymfileArchive.ts new file mode 100644 index 0000000000..4db36a4a09 --- /dev/null +++ b/packages/build-tools/src/templates/GymfileArchive.ts @@ -0,0 +1,24 @@ +export const GymfileArchiveTemplate = `suppress_xcode_output(true) +clean(<%- CLEAN %>) + +scheme("<%- SCHEME %>") +<% if (SCHEME_BUILD_CONFIGURATION) { %> +configuration("<%- SCHEME_BUILD_CONFIGURATION %>") +<% } %> + +export_options({ + method: "<%- EXPORT_METHOD %>", + provisioningProfiles: {<% _.forEach(PROFILES, function(profile) { %> + "<%- profile.BUNDLE_ID %>" => "<%- profile.UUID %>",<% }); %> + }<% if (ICLOUD_CONTAINER_ENVIRONMENT) { %>, + iCloudContainerEnvironment: "<%- ICLOUD_CONTAINER_ENVIRONMENT %>" +<% } %> +}) + +export_xcargs "OTHER_CODE_SIGN_FLAGS=\\"--keychain <%- KEYCHAIN_PATH %>\\"" + +disable_xcpretty(true) +buildlog_path("<%- LOGS_DIRECTORY %>") + +output_directory("<%- OUTPUT_DIRECTORY %>") +`; diff --git a/packages/build-tools/src/templates/GymfileSimulator.ts b/packages/build-tools/src/templates/GymfileSimulator.ts new file mode 100644 index 0000000000..d6b6be37d6 --- /dev/null +++ b/packages/build-tools/src/templates/GymfileSimulator.ts @@ -0,0 +1,16 @@ +export const GymfileSimulatorTemplate = `suppress_xcode_output(true) +clean(<%- CLEAN %>) + +scheme("<%- SCHEME %>") +<% if (SCHEME_BUILD_CONFIGURATION) { %> +configuration("<%- SCHEME_BUILD_CONFIGURATION %>") +<% } %> + +derived_data_path("<%- DERIVED_DATA_PATH %>") +skip_package_ipa(true) +skip_archive(true) +destination("<%- SCHEME_SIMULATOR_DESTINATION %>") + +disable_xcpretty(true) +buildlog_path("<%- LOGS_DIRECTORY %>") +`; diff --git a/packages/build-tools/src/templates/npmrc.ts b/packages/build-tools/src/templates/npmrc.ts new file mode 100644 index 0000000000..d832bf63ae --- /dev/null +++ b/packages/build-tools/src/templates/npmrc.ts @@ -0,0 +1,3 @@ +export const NpmrcTemplate = `//registry.npmjs.org/:_authToken=\${NPM_TOKEN} +registry=https://registry.npmjs.org/ +`; diff --git a/packages/build-tools/src/utils/AndroidEmulatorUtils.ts b/packages/build-tools/src/utils/AndroidEmulatorUtils.ts new file mode 100644 index 0000000000..8aa0e90ab4 --- /dev/null +++ b/packages/build-tools/src/utils/AndroidEmulatorUtils.ts @@ -0,0 +1,563 @@ +import assert from 'assert'; +import fs from 'node:fs'; +import os from 'node:os'; +import { setTimeout } from 'node:timers/promises'; +import path from 'node:path'; + +import { bunyan } from '@expo/logger'; +import spawn, { SpawnPromise, SpawnResult } from '@expo/turtle-spawn'; +import FastGlob from 'fast-glob'; +import { z } from 'zod'; + +import { retryAsync } from './retry'; + +/** Android Virtual Device is the device we run. */ +export type AndroidVirtualDeviceName = string & z.BRAND<'AndroidVirtualDeviceName'>; +/** Android device is configuration for the AVD -- screen size, etc. */ +export type AndroidDeviceName = string & z.BRAND<'AndroidDeviceName'>; +export type AndroidDeviceSerialId = string & z.BRAND<'AndroidDeviceSerialId'>; + +export namespace AndroidEmulatorUtils { + export const defaultSystemImagePackage = `system-images;android-30;default;${ + process.arch === 'arm64' ? 'arm64-v8a' : 'x86_64' + }`; + + export async function getAvailableDevicesAsync({ + env, + }: { + env: NodeJS.ProcessEnv; + }): Promise { + const result = await spawn('avdmanager', ['list', 'device', '--compact', '--null'], { env }); + return result.stdout.split('\0').filter((line) => line !== '') as AndroidDeviceName[]; + } + + export async function getAttachedDevicesAsync({ + env, + }: { + env: NodeJS.ProcessEnv; + }): Promise<{ serialId: AndroidDeviceSerialId; state: 'offline' | 'device' }[]> { + const result = await spawn('adb', ['devices', '-l'], { + env, + }); + return result.stdout + .replace(/\r\n/g, '\n') + .split('\n') + .filter((line) => line.startsWith('emulator')) + .map((line) => { + const [serialId, state] = line.split(/\s+/) as [ + AndroidDeviceSerialId, + 'offline' | 'device', + ]; + return { + serialId, + state, + }; + }); + } + + export async function getSerialIdAsync({ + deviceName, + env, + }: { + deviceName: AndroidVirtualDeviceName; + env: NodeJS.ProcessEnv; + }): Promise { + const adbDevices = await spawn('adb', ['devices'], { env }); + for (const adbDeviceLine of adbDevices.stdout.split('\n')) { + if (!adbDeviceLine.startsWith('emulator')) { + continue; + } + + const matches = adbDeviceLine.match(/^(\S+)/); + if (!matches) { + continue; + } + + const [, serialId] = matches; + // Previously we were using `qemu.uuid` to identify the emulator, + // but this does not work for newer emulators, because there is + // a limit on properties and custom properties get ignored. + // See https://stackoverflow.com/questions/2214377/how-to-get-serial-number-or-id-of-android-emulator-after-it-runs#comment98259121_42038655 + const adbEmuAvdName = await spawn('adb', ['-s', serialId, 'emu', 'avd', 'name'], { + env, + }); + if (adbEmuAvdName.stdout.replace(/\r\n/g, '\n').split('\n')[0] === deviceName) { + return serialId as AndroidDeviceSerialId; + } + } + + return null; + } + + export async function createAsync({ + deviceName, + systemImagePackage, + deviceIdentifier, + env, + logger, + }: { + deviceName: AndroidVirtualDeviceName; + systemImagePackage: string; + deviceIdentifier: AndroidDeviceName | null; + env: NodeJS.ProcessEnv; + logger: bunyan; + }): Promise { + const avdManager = spawn( + 'avdmanager', + [ + 'create', + 'avd', + '--name', + deviceName, + '--package', + systemImagePackage, + '--force', + ...(deviceIdentifier ? ['--device', deviceIdentifier] : []), + ], + { + env, + stdio: 'pipe', + } + ); + // `avdmanager create` always asks about creating a custom hardware profile. + // > Do you wish to create a custom hardware profile? [no] + // We answer "no". + avdManager.child.stdin?.write('no'); + avdManager.child.stdin?.end(); + await avdManager; + + // Add extra config to the device's ini file. + const configIniFile = `${env.HOME}/.android/avd/${deviceName}.avd/config.ini`; + try { + let configIniFileContent = await fs.promises.readFile(configIniFile, 'utf-8'); + + logger.info('Setting hw.ramSize to 2048.'); + configIniFileContent = `${configIniFileContent}\nhw.ramSize=2048\n`; + + const shouldResizeScreen = + env.ANDROID_EMULATOR_ADJUST_SCREEN === 'true' || env.ANDROID_EMULATOR_ADJUST_SCREEN === '1'; + if (shouldResizeScreen) { + const currentDensityString = configIniFileContent.match(/hw.lcd.density=(\d+)/)?.[1]; + const currentDensity = currentDensityString + ? parseInt(currentDensityString, 10) + : undefined; + const currentHeightString = configIniFileContent.match(/hw.lcd.height=(\d+)/)?.[1]; + const currentHeight = currentHeightString ? parseInt(currentHeightString, 10) : undefined; + const currentWidthString = configIniFileContent.match(/hw.lcd.width=(\d+)/)?.[1]; + const currentWidth = currentWidthString ? parseInt(currentWidthString, 10) : undefined; + + if (currentDensity && currentDensity > 220) { + logger.info( + `Current density is ${currentDensity}, which we believe may impact performance.` + ); + if (currentHeight && currentWidth) { + const newDensity = 220; + logger.info(`Setting hw.lcd.density to ${newDensity}.`); + configIniFileContent = `${configIniFileContent}\nhw.lcd.density=${newDensity}\n`; + + const newHeight = Math.round((currentHeight * newDensity) / currentDensity); + const newWidth = Math.round((currentWidth * newDensity) / currentDensity); + logger.info( + `Setting scaled screen resolution: hw.lcd.height to ${newHeight} and hw.lcd.width to ${newWidth}.` + ); + configIniFileContent = `${configIniFileContent}\nhw.lcd.height=${newHeight}\nhw.lcd.width=${newWidth}\n`; + } else { + logger.info( + 'Could not find current screen resolution, setting to 1170x540 and 220 ppi.' + ); + configIniFileContent = `${configIniFileContent}\nhw.lcd.height=${1170}\nhw.lcd.width=${540}\nhw.lcd.density=220\n`; + } + } + } + + const shouldAdjustHeapSize = + env.ANDROID_EMULATOR_ADJUST_HEAP_SIZE !== 'false' && + env.ANDROID_EMULATOR_ADJUST_HEAP_SIZE !== '0'; + if (shouldAdjustHeapSize) { + const heapSizeString = configIniFileContent.match(/vm.heapSize=(\d\w+)/)?.[1]; + if (!heapSizeString) { + logger.info('Setting vm.heapSize to 768 MB.'); + configIniFileContent = `${configIniFileContent}\nvm.heapSize=768\n`; + } else if (heapSizeString) { + const heapSize = parseInt(heapSizeString, 10); + const lowerCaseHeapSizeString = heapSizeString.toLocaleLowerCase(); + if (lowerCaseHeapSizeString.includes('g')) { + logger.info('vm.heapSize is in GB, skipping adjustment.'); + } else if (heapSize < 768) { + logger.info('Bumping vm.heapSize to 768 MB.'); + configIniFileContent = `${configIniFileContent}\nvm.heapSize=768\n`; + } + } + } + + if (env.ANDROID_EMULATOR_EXTRA_CONFIG) { + logger.info( + `Adding extra config from $ANDROID_EMULATOR_EXTRA_CONFIG:\n${env.ANDROID_EMULATOR_EXTRA_CONFIG}` + ); + configIniFileContent = `${configIniFileContent}\n${env.ANDROID_EMULATOR_EXTRA_CONFIG}\n`; + } + + await fs.promises.writeFile(configIniFile, configIniFileContent); + } catch (err) { + logger.warn({ err }, `Failed to add extra config to ${configIniFile}.`); + } + } + + export async function cloneAsync({ + sourceDeviceName, + destinationDeviceName, + env, + logger, + }: { + sourceDeviceName: AndroidVirtualDeviceName; + destinationDeviceName: AndroidVirtualDeviceName; + env: NodeJS.ProcessEnv; + logger: bunyan; + }): Promise { + const cloneIniFile = `${env.HOME}/.android/avd/${destinationDeviceName}.ini`; + + try { + // Clean destination device files + await fs.promises.rm(`${env.HOME}/.android/avd/${destinationDeviceName}.avd`, { + recursive: true, + force: true, + }); + await fs.promises.rm(cloneIniFile, { force: true }); + } catch (err) { + logger.warn({ err }, `Failed to remove destination device files ${destinationDeviceName}.`); + } + + try { + // Remove lockfiles from source device + const sourceLockfiles = await FastGlob('./**/*.lock', { + cwd: `${env.HOME}/.android/avd/${sourceDeviceName}.avd`, + absolute: true, + }); + await Promise.all( + sourceLockfiles.map((lockfile) => fs.promises.rm(lockfile, { force: true })) + ); + } catch (err) { + logger.warn({ err }, `Failed to remove lockfiles from source device ${sourceDeviceName}.`); + } + + // Copy source to destination + await fs.promises.cp( + `${env.HOME}/.android/avd/${sourceDeviceName}.avd`, + `${env.HOME}/.android/avd/${destinationDeviceName}.avd`, + { recursive: true, verbatimSymlinks: true, force: true } + ); + + await fs.promises.cp(`${env.HOME}/.android/avd/${sourceDeviceName}.ini`, cloneIniFile, { + verbatimSymlinks: true, + force: true, + }); + + // Remove lockfiles from destination device + try { + const lockfiles = await FastGlob('./**/*.lock', { + cwd: `${env.HOME}/.android/avd/${destinationDeviceName}.avd`, + absolute: true, + }); + await Promise.all(lockfiles.map((lockfile) => fs.promises.rm(lockfile, { force: true }))); + } catch (err) { + logger.warn( + { err }, + `Failed to remove lockfiles from destination device ${destinationDeviceName}.` + ); + } + + const filesToReplaceDeviceNameIn = // TODO: Test whether we need to use `spawnAsync` here. + ( + await spawn('grep', [ + '--binary-files=without-match', + '--recursive', + '--files-with-matches', + `${sourceDeviceName}`, + `${env.HOME}/.android/avd/${destinationDeviceName}.avd`, + ]) + ).stdout + .split('\n') + .filter((file) => file !== ''); + + for (const file of [...filesToReplaceDeviceNameIn, cloneIniFile]) { + try { + const txtFile = await fs.promises.readFile(file, 'utf-8'); + const replaceRegex = new RegExp(`${sourceDeviceName}`, 'g'); + const updatedTxtFile = txtFile.replace(replaceRegex, destinationDeviceName); + await fs.promises.writeFile(file, updatedTxtFile); + } catch (err) { + logger.warn({ err }, `Failed to replace device name in ${file}.`); + } + } + } + + export async function startAsync({ + deviceName, + env, + }: { + deviceName: AndroidVirtualDeviceName; + env: NodeJS.ProcessEnv; + }): Promise<{ emulatorPromise: SpawnPromise; serialId: AndroidDeviceSerialId }> { + const emulatorPromise = spawn( + `${process.env.ANDROID_HOME}/emulator/emulator`, + [ + '-no-window', + '-no-boot-anim', + '-writable-system', + '-noaudio', + '-no-snapshot-save', + '-avd', + deviceName, + '-accel', + 'on', + ...(typeof env.ANDROID_EMULATOR_EXTRA_ARGS === 'string' + ? env.ANDROID_EMULATOR_EXTRA_ARGS.split(' ') + : []), + ], + { + detached: true, + stdio: 'inherit', + env: { + ...env, + // We don't need to wait for emulator to exit gracefully. + ANDROID_EMULATOR_WAIT_TIME_BEFORE_KILL: '1', + }, + } + ); + // If emulator fails to start, throw its error. + if (!emulatorPromise.child.pid) { + await emulatorPromise; + } + emulatorPromise.child.unref(); + + const serialId = await retryAsync( + async () => { + const serialId = await getSerialIdAsync({ deviceName, env }); + assert( + serialId, + `Failed to configure emulator (${serialId}): emulator with required ID not found.` + ); + return serialId; + }, + { + retryOptions: { + retries: 3 * 60, + retryIntervalMs: 1_000, + }, + } + ); + + // We don't want to await the SpawnPromise here. + // eslint-disable-next-line @typescript-eslint/return-await + return { emulatorPromise, serialId }; + } + + export async function waitForReadyAsync({ + serialId, + env, + }: { + serialId: AndroidDeviceSerialId; + env: NodeJS.ProcessEnv; + }): Promise { + await retryAsync( + async () => { + const { stdout } = await spawn( + 'adb', + ['-s', serialId, 'shell', 'getprop', 'sys.boot_completed'], + { env } + ); + + if (!stdout.startsWith('1')) { + throw new Error(`Emulator (${serialId}) boot has not completed.`); + } + }, + { + // Retry every second for 3 minutes. + retryOptions: { + retries: 3 * 60, + retryIntervalMs: 1_000, + }, + } + ); + } + + export async function collectLogsAsync({ + serialId, + env, + }: { + serialId: AndroidDeviceSerialId; + env: NodeJS.ProcessEnv; + }): Promise<{ outputPath: string }> { + const outputDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'android-emulator-logs-')); + const outputPath = path.join(outputDir, `${serialId}.log`); + + // Pipe adb logcat output directly to the file to avoid loading it all into memory + await new Promise((resolve, reject) => { + const { child } = spawn('adb', ['-s', serialId, 'logcat', '-d'], { + env, + stdio: ['ignore', 'pipe', 'inherit'], + }); + + if (!child.stdout) { + reject(new Error('"adb logcat" did not start correctly.')); + return; + } + + const writeStream = fs.createWriteStream(outputPath); + child.stdout.pipe(writeStream); + child.stdout.on('error', reject); + + child.on('error', reject); + writeStream.on('error', reject); + + child.on('close', (code) => { + if (code === 0) { + resolve(); + } else { + reject(new Error(`"adb logcat" exited with code ${code}`)); + } + }); + }); + + return { outputPath }; + } + + export async function deleteAsync({ + serialId, + env, + }: { + serialId: AndroidDeviceSerialId; + env: NodeJS.ProcessEnv; + }): Promise { + const adbEmuAvdName = await spawn('adb', ['-s', serialId, 'emu', 'avd', 'name'], { + env, + }); + const deviceName = adbEmuAvdName.stdout.replace(/\r\n/g, '\n').split('\n')[0]; + + await spawn('adb', ['-s', serialId, 'emu', 'kill'], { env }); + + await retryAsync( + async () => { + const devices = await getAttachedDevicesAsync({ env }); + if (devices.some((device) => device.serialId === serialId)) { + throw new Error(`Emulator (${serialId}) is still attached.`); + } + }, + { + retryOptions: { + retries: 3 * 60, + retryIntervalMs: 1_000, + }, + } + ); + + await spawn('avdmanager', ['delete', 'avd', '-n', deviceName], { env }); + } + + export async function startScreenRecordingAsync({ + serialId, + env, + }: { + serialId: AndroidDeviceSerialId; + env: NodeJS.ProcessEnv; + }): Promise<{ + recordingSpawn: SpawnPromise; + }> { + let isReady = false; + + // Ensure /sdcard/ is ready to write to. (If the emulator was just booted, it might not be ready yet.) + for (let i = 0; i < 30; i++) { + try { + await spawn('adb', ['-s', serialId, 'shell', 'touch', '/sdcard/.expo-recording-ready'], { + env, + }); + isReady = true; + break; + } catch { + await setTimeout(1000); + } + } + + if (!isReady) { + throw new Error(`Emulator (${serialId}) filesystem was not ready in time.`); + } + + const screenrecordArgs = [ + '-s', + serialId, + 'shell', + 'screenrecord', + '--verbose', + '/sdcard/expo-recording.mp4', + ]; + + const screenrecordHelp = await spawn( + 'adb', + ['-s', serialId, 'shell', 'screenrecord', '--help'], + { + env, + } + ); + + if (screenrecordHelp.stdout.includes('remove the time limit')) { + screenrecordArgs.push('--time-limit', '0'); + } + + const recordingSpawn = spawn('adb', screenrecordArgs, { + env, + stdio: 'pipe', + }); + recordingSpawn.child.unref(); + + // We are returning the SpawnPromise here, so we don't await it. + // eslint-disable-next-line @typescript-eslint/return-await + return { + recordingSpawn, + }; + } + + export async function stopScreenRecordingAsync({ + serialId, + recordingSpawn, + env, + }: { + serialId: AndroidDeviceSerialId; + recordingSpawn: SpawnPromise; + env: NodeJS.ProcessEnv; + }): Promise<{ outputPath: string }> { + recordingSpawn.child.kill(1); + + try { + await recordingSpawn; + } catch { + // do nothing + } + + let isRecordingBusy = true; + for (let i = 0; i < 30; i++) { + const lsof = await spawn( + 'adb', + ['-s', serialId, 'shell', 'lsof -t /sdcard/expo-recording.mp4'], + { env } + ); + if (lsof.stdout.trim() === '') { + isRecordingBusy = false; + break; + } + await setTimeout(1000); + } + + if (isRecordingBusy) { + throw new Error(`Recording file is busy.`); + } + + const outputDir = await fs.promises.mkdtemp( + path.join(os.tmpdir(), 'android-screen-recording-') + ); + const outputPath = path.join(outputDir, `${serialId}.mp4`); + + await spawn('adb', ['-s', serialId, 'pull', '/sdcard/expo-recording.mp4', outputPath], { env }); + + return { outputPath }; + } +} diff --git a/packages/build-tools/src/utils/IosSimulatorUtils.ts b/packages/build-tools/src/utils/IosSimulatorUtils.ts new file mode 100644 index 0000000000..1d75144982 --- /dev/null +++ b/packages/build-tools/src/utils/IosSimulatorUtils.ts @@ -0,0 +1,285 @@ +import path from 'node:path'; +import os from 'node:os'; +import fs from 'node:fs'; +import { setTimeout } from 'node:timers/promises'; + +import spawn, { SpawnPromise, SpawnResult } from '@expo/turtle-spawn'; +import { z } from 'zod'; + +import { retryAsync } from './retry'; + +export type IosSimulatorUuid = string & z.BRAND<'IosSimulatorUuid'>; +export type IosSimulatorName = string & z.BRAND<'IosSimulatorName'>; + +export namespace IosSimulatorUtils { + type XcrunSimctlDevice = { + availabilityError?: string; + /** e.g. /Users/sjchmiela/Library/Developer/CoreSimulator/Devices/8272DEB1-42B5-4F78-AB2D-0BC5F320B822/data */ + dataPath: string; + /** e.g. 18341888 */ + dataPathSize: number; + /** e.g. /Users/sjchmiela/Library/Logs/CoreSimulator/8272DEB1-42B5-4F78-AB2D-0BC5F320B822 */ + logPath: string; + /** e.g. 8272DEB1-42B5-4F78-AB2D-0BC5F320B822 */ + udid: IosSimulatorUuid; + isAvailable: boolean; + /** e.g. com.apple.CoreSimulator.SimDeviceType.iPhone-13-mini */ + deviceTypeIdentifier: string; + state: 'Shutdown' | 'Booted'; + /** e.g. iPhone 15 */ + name: IosSimulatorName; + /** e.g. 2024-01-22T19:28:56Z */ + lastBootedAt?: string; + }; + + type SimulatorDevice = XcrunSimctlDevice & { runtime: string; displayName: string }; + + type XcrunSimctlListDevicesJsonOutput = { + devices: { + [runtime: string]: XcrunSimctlDevice[]; + }; + }; + + export async function getAvailableDevicesAsync({ + env, + filter, + }: { + env: NodeJS.ProcessEnv; + filter: 'available' | 'booted'; + }): Promise { + const result = await spawn( + 'xcrun', + ['simctl', 'list', 'devices', '--json', '--no-escape-slashes', filter], + { env } + ); + const xcrunData = JSON.parse(result.stdout) as XcrunSimctlListDevicesJsonOutput; + + const allAvailableDevices: SimulatorDevice[] = []; + for (const [runtime, devices] of Object.entries(xcrunData.devices)) { + allAvailableDevices.push( + ...devices.map((device) => ({ + ...device, + runtime, + displayName: `${device.name} (${device.udid}) on ${runtime}`, + })) + ); + } + + return allAvailableDevices; + } + + export async function getDeviceAsync({ + udid, + env, + }: { + env: NodeJS.ProcessEnv; + udid: IosSimulatorUuid; + }): Promise { + const devices = await getAvailableDevicesAsync({ env, filter: 'available' }); + return devices.find((device) => device.udid === udid) ?? null; + } + + export async function cloneAsync({ + sourceDeviceIdentifier, + destinationDeviceName, + env, + }: { + sourceDeviceIdentifier: IosSimulatorName | IosSimulatorUuid; + destinationDeviceName: IosSimulatorName; + env: NodeJS.ProcessEnv; + }): Promise { + await spawn('xcrun', ['simctl', 'clone', sourceDeviceIdentifier, destinationDeviceName], { + env, + }); + } + + export async function startAsync({ + deviceIdentifier, + env, + }: { + deviceIdentifier: IosSimulatorUuid | IosSimulatorName; + env: NodeJS.ProcessEnv; + }): Promise<{ udid: IosSimulatorUuid }> { + const bootstatusResult = await spawn( + 'xcrun', + ['simctl', 'bootstatus', deviceIdentifier, '-b'], + { + env, + } + ); + + const udid = parseUdidFromBootstatusStdout(bootstatusResult.stdout); + if (!udid) { + throw new Error('Failed to parse UDID from bootstatus result.'); + } + + return { udid }; + } + + export async function waitForReadyAsync({ + udid, + env, + }: { + udid: IosSimulatorUuid; + env: NodeJS.ProcessEnv; + }): Promise { + await retryAsync( + async () => { + await spawn('xcrun', ['simctl', 'io', udid, 'screenshot', '/dev/null'], { + env, + }); + }, + { + retryOptions: { + // There's 30 * 60 seconds in 30 minutes, which is the timeout. + retries: 30 * 60, + retryIntervalMs: 1_000, + }, + } + ); + + // Wait for data migration to complete before declaring the simulator ready + // Based on WebKit's approach: https://trac.webkit.org/changeset/231452/webkit + await retryAsync( + async () => { + const isDataMigrating = await isDataMigratorProcessRunning({ env }); + if (isDataMigrating) { + throw new Error('com.apple.datamigrator still running'); + } + }, + { + retryOptions: { + retries: 30 * 60, + retryIntervalMs: 1_000, + }, + } + ); + } + + export async function collectLogsAsync({ + deviceIdentifier, + env, + }: { + deviceIdentifier: IosSimulatorName | IosSimulatorUuid; + env: NodeJS.ProcessEnv; + }): Promise<{ outputPath: string }> { + const outputDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'ios-simulator-logs-')); + const outputPath = path.join(outputDir, `${deviceIdentifier}.logarchive`); + + await spawn( + 'xcrun', + ['simctl', 'spawn', deviceIdentifier, 'log', 'collect', '--output', outputPath], + { + env, + } + ); + + return { outputPath }; + } + + export async function deleteAsync({ + deviceIdentifier, + env, + }: { + deviceIdentifier: IosSimulatorName | IosSimulatorUuid; + env: NodeJS.ProcessEnv; + }): Promise { + await spawn('xcrun', ['simctl', 'shutdown', deviceIdentifier], { env }); + await spawn('xcrun', ['simctl', 'delete', deviceIdentifier], { env }); + } + + export async function startScreenRecordingAsync({ + deviceIdentifier, + env, + }: { + deviceIdentifier: IosSimulatorUuid | IosSimulatorName; + env: NodeJS.ProcessEnv; + }): Promise<{ + recordingSpawn: SpawnPromise; + outputPath: string; + }> { + const outputDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'ios-screen-recording-')); + const outputPath = path.join(outputDir, `${deviceIdentifier}.mov`); + const recordingSpawn = spawn( + 'xcrun', + ['simctl', 'io', deviceIdentifier, 'recordVideo', '-f', outputPath], + { + env, + } + ); + + const stdout = recordingSpawn.child.stdout; + const stderr = recordingSpawn.child.stderr; + if (!stdout || !stderr) { + // No stdout/stderr means the process failed to start, so awaiting it will throw an error. + await recordingSpawn; + throw new Error('Recording process failed to start.'); + } + + let outputAggregated = ''; + + // Listen to both stdout and stderr since "Recording started" might come from either + stdout.on('data', (data) => { + const output = data.toString(); + outputAggregated += output; + }); + + stderr.on('data', (data) => { + const output = data.toString(); + outputAggregated += output; + }); + + let isRecordingStarted = false; + for (let i = 0; i < 20; i++) { + // Check if recording started message appears in either stdout or stderr + if (outputAggregated.includes('Recording started')) { + isRecordingStarted = true; + break; + } + await setTimeout(1000); + } + + if (!isRecordingStarted) { + throw new Error('Recording not started in time.'); + } + + return { recordingSpawn, outputPath }; + } + + export async function stopScreenRecordingAsync({ + recordingSpawn, + }: { + recordingSpawn: SpawnPromise; + }): Promise { + recordingSpawn.child.kill(2); + await recordingSpawn; + } + + /** + * Check if any com.apple.datamigrator processes are running. + * The existence of these processes indicates that simulators are still booting/migrating data. + * Based on WebKit's approach: https://trac.webkit.org/changeset/231452/webkit + */ + export async function isDataMigratorProcessRunning({ + env, + }: { + env: NodeJS.ProcessEnv; + }): Promise { + try { + const result = await spawn('ps', ['-eo', 'pid,comm'], { env }); + + return result.stdout.includes('com.apple.datamigrator'); + } catch { + // If ps command fails, assume no data migration processes are running + return false; + } + } +} + +function parseUdidFromBootstatusStdout(stdout: string): IosSimulatorUuid | null { + const matches = stdout.match(/^Monitoring boot status for .+ \((.+)\)\.$/m); + if (!matches) { + return null; + } + return matches[1] as IosSimulatorUuid; +} diff --git a/packages/build-tools/src/utils/__integration-tests__/AndroidEmulatorUtils.test.ts b/packages/build-tools/src/utils/__integration-tests__/AndroidEmulatorUtils.test.ts new file mode 100644 index 0000000000..a498aa8801 --- /dev/null +++ b/packages/build-tools/src/utils/__integration-tests__/AndroidEmulatorUtils.test.ts @@ -0,0 +1,188 @@ +import fs from 'node:fs'; +import { setTimeout } from 'timers/promises'; + +import spawn from '@expo/turtle-spawn'; +import { asyncResult } from '@expo/results'; + +import { + AndroidDeviceSerialId, + AndroidEmulatorUtils, + AndroidVirtualDeviceName, +} from '../AndroidEmulatorUtils'; +import { createMockLogger } from '../../__tests__/utils/logger'; + +// We need to use real fs for cloning devices to work. +jest.unmock('fs'); +jest.unmock('node:fs'); + +describe('AndroidEmulatorUtils', () => { + beforeEach(async () => { + const devices = await AndroidEmulatorUtils.getAttachedDevicesAsync({ env: process.env }); + for (const { serialId } of devices) { + try { + await AndroidEmulatorUtils.deleteAsync({ + serialId, + env: process.env, + }); + } catch (error) { + console.error('Failed to delete emulator', error); + } + } + }); + + afterAll(async () => { + const devices = await AndroidEmulatorUtils.getAttachedDevicesAsync({ env: process.env }); + for (const { serialId } of devices) { + try { + await AndroidEmulatorUtils.deleteAsync({ + serialId, + env: process.env, + }); + } catch (error) { + console.error('Failed to delete emulator', error); + } + } + }); + + describe('getAvailableDevicesAsync', () => { + it('should return available devices', async () => { + const devices = await AndroidEmulatorUtils.getAvailableDevicesAsync({ env: process.env }); + expect(devices).toContain('Nexus 6'); + }); + }); + + describe('getAttachedDevicesAsync', () => { + it('should return no attached devices if no devices are attached', async () => { + const devices = await AndroidEmulatorUtils.getAttachedDevicesAsync({ env: process.env }); + expect(devices).toEqual([]); + }); + + it('should return connected devices if devices are connected', async () => { + const deviceName = 'android-emulator-connected-utils-test' as AndroidVirtualDeviceName; + let serialId: AndroidDeviceSerialId | null = null; + + await AndroidEmulatorUtils.createAsync({ + deviceName, + systemImagePackage: AndroidEmulatorUtils.defaultSystemImagePackage, + deviceIdentifier: null, + env: process.env, + logger: createMockLogger({ logToConsole: true }), + }); + ({ serialId } = await AndroidEmulatorUtils.startAsync({ + deviceName, + env: { ...process.env, ANDROID_EMULATOR_WAIT_TIME_BEFORE_KILL: '1' }, + })); + await AndroidEmulatorUtils.waitForReadyAsync({ + serialId, + env: process.env, + }); + + const devices = await AndroidEmulatorUtils.getAttachedDevicesAsync({ env: process.env }); + expect(devices.map((device) => device.serialId)).toContain(serialId); + }, 30_000); + }); + + it('should work end-to-end', async () => { + const deviceName = 'android-emulator-end-to-end-test' as AndroidVirtualDeviceName; + await AndroidEmulatorUtils.createAsync({ + deviceName, + systemImagePackage: AndroidEmulatorUtils.defaultSystemImagePackage, + deviceIdentifier: null, + env: process.env, + logger: createMockLogger({ logToConsole: true }), + }); + const { serialId, emulatorPromise } = await AndroidEmulatorUtils.startAsync({ + deviceName, + env: { ...process.env, ANDROID_EMULATOR_WAIT_TIME_BEFORE_KILL: '1' }, + }); + await AndroidEmulatorUtils.waitForReadyAsync({ + serialId, + env: process.env, + }); + + const { stdout } = await spawn('adb', ['-s', serialId, 'shell', 'ls'], { + env: process.env, + }); + expect(stdout).toContain('data'); + + await spawn('adb', ['-s', serialId, 'emu', 'kill'], { env: process.env }); + await asyncResult(emulatorPromise); + + const cloneDeviceName = (deviceName + '-clone') as AndroidVirtualDeviceName; + await AndroidEmulatorUtils.cloneAsync({ + sourceDeviceName: deviceName, + destinationDeviceName: cloneDeviceName, + env: process.env, + logger: createMockLogger({ logToConsole: true }), + }); + + const { serialId: serialIdClone, emulatorPromise: emulatorPromiseClone } = + await AndroidEmulatorUtils.startAsync({ + deviceName: cloneDeviceName, + env: { ...process.env, ANDROID_EMULATOR_WAIT_TIME_BEFORE_KILL: '1' }, + }); + await AndroidEmulatorUtils.waitForReadyAsync({ + serialId: serialIdClone, + env: process.env, + }); + + const { stdout: stdoutClone } = await spawn('adb', ['-s', serialIdClone, 'shell', 'ls'], { + env: process.env, + }); + expect(stdoutClone).toContain('data'); + + await AndroidEmulatorUtils.deleteAsync({ + serialId, + env: process.env, + }); + await asyncResult(emulatorPromiseClone); + }, 60_000); + + it('should work with screen recording', async () => { + const deviceName = 'android-emulator-screen-recording-test' as AndroidVirtualDeviceName; + await AndroidEmulatorUtils.createAsync({ + deviceName, + systemImagePackage: AndroidEmulatorUtils.defaultSystemImagePackage, + deviceIdentifier: null, + env: process.env, + logger: createMockLogger({ logToConsole: true }), + }); + + const { serialId, emulatorPromise } = await AndroidEmulatorUtils.startAsync({ + deviceName, + env: { ...process.env, ANDROID_EMULATOR_WAIT_TIME_BEFORE_KILL: '1' }, + }); + + await AndroidEmulatorUtils.waitForReadyAsync({ + serialId, + env: process.env, + }); + + const { recordingSpawn } = await AndroidEmulatorUtils.startScreenRecordingAsync({ + serialId, + env: process.env, + }); + + await setTimeout(5_000); + + const { outputPath } = await AndroidEmulatorUtils.stopScreenRecordingAsync({ + serialId, + recordingSpawn, + env: process.env, + }); + + const { size } = await fs.promises.stat(outputPath); + expect(size).toBeGreaterThan(1024); + + const { stdout } = await spawn('file', ['-I', '-b', outputPath], { + env: process.env, + }); + expect(stdout).toContain('video/mp4'); + + await AndroidEmulatorUtils.deleteAsync({ + serialId, + env: process.env, + }); + await asyncResult(emulatorPromise); + }, 60_000); +}); diff --git a/packages/build-tools/src/utils/__integration-tests__/IosSimulatorUtils.test.ts b/packages/build-tools/src/utils/__integration-tests__/IosSimulatorUtils.test.ts new file mode 100644 index 0000000000..ef5ad6195b --- /dev/null +++ b/packages/build-tools/src/utils/__integration-tests__/IosSimulatorUtils.test.ts @@ -0,0 +1,193 @@ +import fs from 'node:fs'; +import { setTimeout } from 'timers/promises'; + +import spawn from '@expo/turtle-spawn'; + +import { IosSimulatorName, IosSimulatorUtils } from '../IosSimulatorUtils'; + +// We need to use real fs for cloning devices to work. +jest.unmock('fs'); +jest.unmock('node:fs'); + +describe('IosSimulatorUtils', () => { + beforeEach(async () => { + const devices = await IosSimulatorUtils.getAvailableDevicesAsync({ + env: process.env, + filter: 'booted', + }); + for (const { udid } of devices) { + try { + await IosSimulatorUtils.deleteAsync({ + deviceIdentifier: udid, + env: process.env, + }); + } catch (error) { + console.error('Failed to delete emulator', error); + } + } + }); + + afterAll(async () => { + const devices = await IosSimulatorUtils.getAvailableDevicesAsync({ + env: process.env, + filter: 'booted', + }); + for (const { udid } of devices) { + try { + await IosSimulatorUtils.deleteAsync({ + deviceIdentifier: udid, + env: process.env, + }); + } catch (error) { + console.error('Failed to delete simulator', error); + } + } + }); + + describe('getAvailableDevicesAsync(filter: "available")', () => { + it('should return available devices', async () => { + const devices = await IosSimulatorUtils.getAvailableDevicesAsync({ + env: process.env, + filter: 'available', + }); + expect(devices).toContainEqual( + expect.objectContaining({ name: expect.stringMatching(/iPhone \d+/) }) + ); + }); + }); + + describe('getAttachedDevicesAsync(filter: "booted")', () => { + it('should return no attached devices if no devices are attached', async () => { + const devices = await IosSimulatorUtils.getAvailableDevicesAsync({ + env: process.env, + filter: 'booted', + }); + expect(devices).toEqual([]); + }); + + it('should return booted devices if devices are booted', async () => { + const deviceName = 'ios-booted-test' as IosSimulatorName; + + await IosSimulatorUtils.cloneAsync({ + sourceDeviceIdentifier: 'iPhone 16' as IosSimulatorName, + destinationDeviceName: deviceName, + env: process.env, + }); + + const { udid } = await IosSimulatorUtils.startAsync({ + deviceIdentifier: deviceName, + env: process.env, + }); + + await IosSimulatorUtils.waitForReadyAsync({ + udid, + env: process.env, + }); + + const devices = await IosSimulatorUtils.getAvailableDevicesAsync({ + env: process.env, + filter: 'booted', + }); + expect(devices.map((device) => device.udid)).toContain(udid); + }, 60_000); + }); + + it('should work end-to-end', async () => { + const deviceName = 'ios-end-to-end-test' as IosSimulatorName; + + await IosSimulatorUtils.cloneAsync({ + sourceDeviceIdentifier: 'iPhone 16' as IosSimulatorName, + destinationDeviceName: deviceName, + env: process.env, + }); + + const { udid } = await IosSimulatorUtils.startAsync({ + deviceIdentifier: deviceName, + env: process.env, + }); + await IosSimulatorUtils.waitForReadyAsync({ + udid, + env: process.env, + }); + + const { stdout } = await spawn('xcrun', ['simctl', 'ui', udid, 'appearance'], { + env: process.env, + }); + expect(stdout).toContain('light'); + + await spawn('xcrun', ['simctl', 'shutdown', udid], { env: process.env }); + + const cloneDeviceName = (deviceName + '-clone') as IosSimulatorName; + await IosSimulatorUtils.cloneAsync({ + sourceDeviceIdentifier: deviceName, + destinationDeviceName: cloneDeviceName, + env: process.env, + }); + + const { udid: udidClone } = await IosSimulatorUtils.startAsync({ + deviceIdentifier: cloneDeviceName, + env: process.env, + }); + await IosSimulatorUtils.waitForReadyAsync({ + udid: udidClone, + env: process.env, + }); + + const { stdout: stdoutClone } = await spawn( + 'xcrun', + ['simctl', 'ui', udidClone, 'appearance'], + { + env: process.env, + } + ); + expect(stdoutClone).toContain('light'); + + await IosSimulatorUtils.deleteAsync({ + deviceIdentifier: udidClone, + env: process.env, + }); + }, 60_000); + + it('should work with screen recording', async () => { + const deviceName = 'ios-screen-recording-test' as IosSimulatorName; + await IosSimulatorUtils.cloneAsync({ + sourceDeviceIdentifier: 'iPhone 16' as IosSimulatorName, + destinationDeviceName: deviceName, + env: process.env, + }); + + const { udid } = await IosSimulatorUtils.startAsync({ + deviceIdentifier: deviceName, + env: process.env, + }); + + await IosSimulatorUtils.waitForReadyAsync({ + udid, + env: process.env, + }); + + const { recordingSpawn, outputPath } = await IosSimulatorUtils.startScreenRecordingAsync({ + deviceIdentifier: udid, + env: process.env, + }); + + await setTimeout(5_000); + + await IosSimulatorUtils.stopScreenRecordingAsync({ + recordingSpawn, + }); + + const { size } = await fs.promises.stat(outputPath); + expect(size).toBeGreaterThan(1024); + + const { stdout } = await spawn('file', ['-I', '-b', outputPath], { + env: process.env, + }); + expect(stdout).toContain('video/quicktime'); + + await IosSimulatorUtils.deleteAsync({ + deviceIdentifier: udid, + env: process.env, + }); + }, 60_000); +}); diff --git a/packages/build-tools/src/utils/__integration-tests__/findMaestroPathsFlowsToExecuteAsync.test.ts b/packages/build-tools/src/utils/__integration-tests__/findMaestroPathsFlowsToExecuteAsync.test.ts new file mode 100644 index 0000000000..ea47149694 --- /dev/null +++ b/packages/build-tools/src/utils/__integration-tests__/findMaestroPathsFlowsToExecuteAsync.test.ts @@ -0,0 +1,401 @@ +import { promises as fs } from 'node:fs'; +import * as path from 'node:path'; + +import bunyan from 'bunyan'; +import spawnAsync from '@expo/spawn-async'; + +import { findMaestroPathsFlowsToExecuteAsync } from '../findMaestroPathsFlowsToExecuteAsync'; +import { createMockLogger } from '../../__tests__/utils/logger'; + +// We use node:fs +jest.unmock('node:fs'); +// and fg uses fs. +jest.unmock('fs'); +jest.setTimeout(15000); + +/** + * + * You need to have a Simulator running to run these tests. + * + */ + +describe('findMaestroPathsFlowsToExecuteAsync', () => { + const fixturesDir = path.join(__dirname, 'fixtures', 'maestro-flows'); + const singleFileDir = path.join(__dirname, 'fixtures', 'single-file'); + const configFlowsDir = path.join(__dirname, 'fixtures', 'config-flows'); + const noConfigFlowsDir = path.join(__dirname, 'fixtures', 'no-config-flows'); + + let logger: bunyan; + + beforeAll(() => { + logger = createMockLogger(); + }); + + // Helper function to run maestro test and determine which flows it executed + // by parsing the output + async function getMaestroFlowList( + flowPath: string, + includeTags: string[] = [], + excludeTags: string[] = [] + ): Promise { + const args = ['test', flowPath, '--no-ansi']; + + if (includeTags.length > 0) { + args.push('--include-tags', includeTags.join(',')); + } + + if (excludeTags.length > 0) { + args.push('--exclude-tags', excludeTags.join(',')); + } + + try { + const result = await spawnAsync('maestro', args, { + stdio: 'pipe', + }); + + return parseMaestroExecutedFlows(result.stdout + result.stderr); + } catch (error: any) { + const output = (error.stdout || '') + (error.stderr || '') + (error.message || ''); + return parseMaestroExecutedFlows(output); + } + } + + function parseMaestroExecutedFlows(output: string): string[] { + return output + .split('\n') + .filter((line) => line.includes('[Passed]') || line.includes('> Flow')) + .map((line) => { + if (line.includes('[Passed]')) { + return line.split('[Passed]')[1].trim().split(' ')[0]; + } + return line.split('> Flow')[1].trim(); + }) + .sort(); + } + + // Helper function to run our implementation + async function getOurFlowList( + flowPath: string, + includeTags: string[] = [], + excludeTags: string[] = [] + ): Promise { + const result = await findMaestroPathsFlowsToExecuteAsync({ + workingDirectory: process.cwd(), + flowPath: path.relative(process.cwd(), flowPath), + includeTags, + excludeTags, + logger, + }); + + return result.map((filePath) => path.basename(filePath, path.extname(filePath))).sort(); + } + + describe('Directory-based flow discovery', () => { + it('should discover all flows when no tags are specified', async () => { + const maestroFlows = await getMaestroFlowList(fixturesDir); + const ourFlows = await getOurFlowList(fixturesDir); + + expect(ourFlows).toEqual(maestroFlows); + }); + + it('should filter flows by include tags', async () => { + const maestroFlows = await getMaestroFlowList(fixturesDir, ['auth']); + const ourFlows = await getOurFlowList(fixturesDir, ['auth']); + + expect(ourFlows).toEqual(maestroFlows); + }); + + it('should filter flows by exclude tags', async () => { + const maestroFlows = await getMaestroFlowList(fixturesDir, [], ['slow']); + const ourFlows = await getOurFlowList(fixturesDir, [], ['slow']); + + expect(ourFlows).toEqual(maestroFlows); + }); + + it('should filter flows by both include and exclude tags', async () => { + const maestroFlows = await getMaestroFlowList(fixturesDir, ['smoke'], ['regression']); + const ourFlows = await getOurFlowList(fixturesDir, ['smoke'], ['regression']); + + expect(ourFlows).toEqual(maestroFlows); + }); + + it('should handle multiple include tags', async () => { + const maestroFlows = await getMaestroFlowList(fixturesDir, ['auth', 'smoke']); + const ourFlows = await getOurFlowList(fixturesDir, ['auth', 'smoke']); + + expect(ourFlows).toEqual(maestroFlows); + }); + + it('should handle multiple exclude tags', async () => { + const maestroFlows = await getMaestroFlowList(fixturesDir, [], ['slow', 'regression']); + const ourFlows = await getOurFlowList(fixturesDir, [], ['slow', 'regression']); + + expect(ourFlows).toEqual(maestroFlows); + }); + + it('should not include nested flows in subdirectories', async () => { + const maestroFlows = await getMaestroFlowList(fixturesDir, ['nested']); + const ourFlows = await getOurFlowList(fixturesDir, ['nested']); + + expect(ourFlows).toEqual(maestroFlows); + }); + + it('should exclude config files from flow discovery', async () => { + const ourFlows = await getOurFlowList(fixturesDir); + + expect(ourFlows.every((flow) => !flow.endsWith('config.yaml'))).toBe(true); + expect(ourFlows.every((flow) => !flow.endsWith('config.yml'))).toBe(true); + }); + + it('should exclude non-YAML files from flow discovery', async () => { + const ourFlows = await getOurFlowList(fixturesDir); + + expect(ourFlows).not.toContain('non-flow-file'); + }); + }); + + describe('Single file scenarios', () => { + it('should handle single file input', async () => { + const singleFile = path.join(singleFileDir, 'standalone-flow.yaml'); + + const maestroFlows = await getMaestroFlowList(singleFile); + const ourFlows = await getOurFlowList(singleFile); + + expect(ourFlows).toEqual(maestroFlows); + expect(ourFlows).toHaveLength(1); + expect(ourFlows[0]).toBe(path.basename(singleFile, path.extname(singleFile))); + }); + + it('should handle single file with tag filtering (include)', async () => { + const singleFile = path.join(singleFileDir, 'standalone-flow.yaml'); + + // This file has 'smoke' tag, so should be included + const maestroFlows = await getMaestroFlowList(singleFile, ['smoke']); + const ourFlows = await getOurFlowList(singleFile, ['smoke']); + + expect(ourFlows).toEqual(maestroFlows); + expect(ourFlows).toHaveLength(1); + }); + + it('should handle single file with tag filtering (exclude)', async () => { + const singleFile = path.join(singleFileDir, 'standalone-flow.yaml'); + + // This file has 'smoke' tag, so should be excluded + const maestroFlows = await getMaestroFlowList(singleFile, [], ['smoke']); + const ourFlows = await getOurFlowList(singleFile, [], ['smoke']); + + expect(ourFlows).toEqual(maestroFlows); + expect(ourFlows).toHaveLength(1); + }); + }); + + describe('Edge cases', () => { + it('should handle non-existent paths gracefully', async () => { + const nonExistentPath = '/path/that/does/not/exist'; + + // Our implementation should return empty array for non-existent paths + await expect(getOurFlowList(nonExistentPath)).rejects.toThrow('ENOENT'); + }); + + it('should handle empty directories', async () => { + const emptyDir = path.join(__dirname, 'fixtures', 'empty-dir'); + await fs.mkdir(emptyDir, { recursive: true }); + + try { + const ourFlows = await getOurFlowList(emptyDir); + expect(ourFlows).toEqual([]); + } finally { + await fs.rmdir(emptyDir); + } + }); + + it('should handle flows that cannot be parsed', async () => { + // The invalid-flow.yaml has extra fields that might cause parsing issues + // but should still be discovered if tag filtering passes + const ourFlows = await getOurFlowList(fixturesDir, ['invalid']); + + // Should either include the file (if parsing is lenient) or exclude it (if parsing fails) + // The important thing is that it doesn't crash + expect(Array.isArray(ourFlows)).toBe(true); + }); + }); + + describe('Performance comparison', () => { + it('should complete discovery in reasonable time', async () => { + const startTime = Date.now(); + + await getOurFlowList(fixturesDir); + + const duration = Date.now() - startTime; + expect(duration).toBeLessThan(5000); // Should complete within 5 seconds + }); + }); + + describe('Detailed flow content verification', () => { + it('should correctly identify flows with specific tag combinations', async () => { + // Test complex tag filtering scenarios + const authFlows = await getOurFlowList(fixturesDir, ['auth']); + const smokeFlows = await getOurFlowList(fixturesDir, ['smoke']); + const criticalFlows = await getOurFlowList(fixturesDir, ['critical']); + + // Verify specific files are included/excluded correctly + expect(authFlows).toContain('tagged-flow-auth'); + expect(authFlows).not.toContain('nested-flow'); // Subdirectory flows excluded by default + expect(smokeFlows).toContain('tagged-flow-smoke'); + expect(criticalFlows).toContain('tagged-flow-smoke'); + }); + + it('should handle flows without tags correctly', async () => { + // When no filters are applied, flows without tags should be included + const allFlows = await getOurFlowList(fixturesDir); + expect(allFlows).toContain('no-tags-flow'); + expect(allFlows).toContain('basic-flow'); + + // When include tags are specified, flows without tags should be excluded + const taggedFlows = await getOurFlowList(fixturesDir, ['auth']); + expect(taggedFlows).not.toContain('no-tags-flow'); + expect(taggedFlows).not.toContain('basic-flow'); + }); + }); + + describe('config.yaml flow patterns', () => { + it('should discover flows using config.yaml patterns', async () => { + const maestroFlows = await getMaestroFlowList(configFlowsDir); + const ourFlows = await getOurFlowList(configFlowsDir); + + expect(ourFlows).toEqual(maestroFlows); + + // Verify specific files are included based on config patterns + expect(ourFlows).toContain('login'); // features/auth/login.yaml + expect(ourFlows).toContain('logout'); // features/auth/logout.yaml + expect(ourFlows).toContain('navigation'); // features/core/navigation.yaml + expect(ourFlows).toContain('api.spec'); // specs/api.spec.yaml + expect(ourFlows).toContain('ui.spec'); // specs/ui.spec.yaml + + // Verify top-level file is excluded (not matching patterns) + expect(ourFlows).not.toContain('ignored-top-level'); + }); + + it('should handle tag filtering with config.yaml patterns', async () => { + const authFlows = await getOurFlowList(configFlowsDir, ['auth']); + const maestroAuthFlows = await getMaestroFlowList(configFlowsDir, ['auth']); + + expect(authFlows).toEqual(maestroAuthFlows); + expect(authFlows).toContain('login'); + expect(authFlows).toContain('logout'); + expect(authFlows).not.toContain('navigation'); + expect(authFlows).not.toContain('api.spec'); + }); + + it('should handle exclude tags with config.yaml patterns', async () => { + const nonSmokeFlows = await getOurFlowList(configFlowsDir, [], ['smoke']); + const maestroNonSmokeFlows = await getMaestroFlowList(configFlowsDir, [], ['smoke']); + + expect(nonSmokeFlows).toEqual(maestroNonSmokeFlows); + expect(nonSmokeFlows).not.toContain('login'); // has smoke tag + expect(nonSmokeFlows).not.toContain('navigation'); // has smoke tag + expect(nonSmokeFlows).toContain('logout'); // no smoke tag + expect(nonSmokeFlows).toContain('api.spec'); // no smoke tag + }); + + it('should handle multiple include tags with config.yaml patterns', async () => { + const multiTagFlows = await getOurFlowList(configFlowsDir, ['auth', 'integration']); + const maestroMultiTagFlows = await getMaestroFlowList(configFlowsDir, [ + 'auth', + 'integration', + ]); + + expect(multiTagFlows).toEqual(maestroMultiTagFlows); + expect(multiTagFlows).toContain('login'); // auth tag + expect(multiTagFlows).toContain('logout'); // auth tag + expect(multiTagFlows).toContain('api.spec'); // integration tag + expect(multiTagFlows).toContain('ui.spec'); // integration tag + expect(multiTagFlows).not.toContain('navigation'); // only core/smoke tags + }); + }); + + describe('config.yaml includeTags/excludeTags merging', () => { + const configTagsDir = path.join(__dirname, 'fixtures', 'config-tags'); + + it('should respect includeTags/excludeTags from config.yaml when no CLI tags provided', async () => { + const maestroFlows = await getMaestroFlowList(configTagsDir); + const ourFlows = await getOurFlowList(configTagsDir); + + expect(ourFlows).toEqual(maestroFlows); + + // Based on fixtures, only flows matching config includeTags and not matching excludeTags should be included + expect(ourFlows).toContain('flow-inc-only'); + expect(ourFlows).not.toContain('flow-exc-only'); + expect(ourFlows).not.toContain('flow-inc-and-exc'); + expect(ourFlows).not.toContain('flow-neutral'); + expect(ourFlows).not.toContain('flow-cli-exclude'); + expect(ourFlows).not.toContain('flow-cli-include'); + }); + + it('should merge CLI includeTags with config includeTags', async () => { + const maestroFlows = await getMaestroFlowList(configTagsDir, ['cliInclude']); + const ourFlows = await getOurFlowList(configTagsDir, ['cliInclude']); + + expect(ourFlows).toEqual(maestroFlows); + // Now flows with either cfgInclude or cliInclude should be included, minus excluded ones + expect(ourFlows).toContain('flow-inc-only'); + expect(ourFlows).toContain('flow-cli-include'); + expect(ourFlows).not.toContain('flow-exc-only'); + expect(ourFlows).not.toContain('flow-inc-and-exc'); + expect(ourFlows).not.toContain('flow-cli-exclude'); + }); + + it('should merge CLI excludeTags with config excludeTags', async () => { + const maestroFlows = await getMaestroFlowList(configTagsDir, [], ['cliExclude']); + const ourFlows = await getOurFlowList(configTagsDir, [], ['cliExclude']); + + expect(ourFlows).toEqual(maestroFlows); + // Exclude both cfgExclude and cliExclude + expect(ourFlows).toContain('flow-inc-only'); + expect(ourFlows).not.toContain('flow-exc-only'); + expect(ourFlows).not.toContain('flow-cli-exclude'); + expect(ourFlows).not.toContain('flow-inc-and-exc'); + }); + + it('should apply both merged include and exclude tags correctly', async () => { + const maestroFlows = await getMaestroFlowList(configTagsDir, ['cfgInclude'], ['cfgExclude']); + const ourFlows = await getOurFlowList(configTagsDir, ['cfgInclude'], ['cfgExclude']); + + expect(ourFlows).toEqual(maestroFlows); + expect(ourFlows).toContain('flow-inc-only'); + expect(ourFlows).not.toContain('flow-exc-only'); + expect(ourFlows).not.toContain('flow-inc-and-exc'); + expect(ourFlows).not.toContain('flow-neutral'); + }); + }); + + describe('backward compatibility (no config.yaml)', () => { + it('should use default "*" pattern when no config.yaml exists', async () => { + const maestroFlows = await getMaestroFlowList(noConfigFlowsDir); + const ourFlows = await getOurFlowList(noConfigFlowsDir); + + expect(ourFlows).toEqual(maestroFlows); + + // Should include top-level files + expect(ourFlows).toContain('top-level-flow'); + + // Should exclude subdirectory files (default "*" behavior) + expect(ourFlows).not.toContain('nested-flow'); + }); + + it('should handle tag filtering without config.yaml', async () => { + const basicFlows = await getOurFlowList(noConfigFlowsDir, ['basic']); + const maestroBasicFlows = await getMaestroFlowList(noConfigFlowsDir, ['basic']); + + expect(basicFlows).toEqual(maestroBasicFlows); + expect(basicFlows).toContain('top-level-flow'); + + const noIncludeTagsFlows = await getOurFlowList(noConfigFlowsDir, []); + const maestroNoIncludeTagsFlows = await getMaestroFlowList(noConfigFlowsDir, []); + + expect(noIncludeTagsFlows).toEqual(maestroNoIncludeTagsFlows); + expect(noIncludeTagsFlows).toContain('top-level-flow'); + expect(noIncludeTagsFlows).not.toContain('nested-flow'); + }); + }); +}); diff --git a/packages/build-tools/src/utils/__integration-tests__/fixtures/config-flows/config.yaml b/packages/build-tools/src/utils/__integration-tests__/fixtures/config-flows/config.yaml new file mode 100644 index 0000000000..d6abb38fd5 --- /dev/null +++ b/packages/build-tools/src/utils/__integration-tests__/fixtures/config-flows/config.yaml @@ -0,0 +1,3 @@ +flows: + - 'features/**/*.yaml' + - 'specs/*.spec.yaml' diff --git a/packages/build-tools/src/utils/__integration-tests__/fixtures/config-flows/features/auth/login.yaml b/packages/build-tools/src/utils/__integration-tests__/fixtures/config-flows/features/auth/login.yaml new file mode 100644 index 0000000000..66345d5048 --- /dev/null +++ b/packages/build-tools/src/utils/__integration-tests__/fixtures/config-flows/features/auth/login.yaml @@ -0,0 +1,5 @@ +appId: com.example.app +name: "login" +tags: ["auth", "smoke"] +--- +- assertTrue: true diff --git a/packages/build-tools/src/utils/__integration-tests__/fixtures/config-flows/features/auth/logout.yaml b/packages/build-tools/src/utils/__integration-tests__/fixtures/config-flows/features/auth/logout.yaml new file mode 100644 index 0000000000..38d0efaed9 --- /dev/null +++ b/packages/build-tools/src/utils/__integration-tests__/fixtures/config-flows/features/auth/logout.yaml @@ -0,0 +1,5 @@ +appId: com.example.app +name: "logout" +tags: ["auth"] +--- +- assertTrue: true diff --git a/packages/build-tools/src/utils/__integration-tests__/fixtures/config-flows/features/core/navigation.yaml b/packages/build-tools/src/utils/__integration-tests__/fixtures/config-flows/features/core/navigation.yaml new file mode 100644 index 0000000000..f7c84175ac --- /dev/null +++ b/packages/build-tools/src/utils/__integration-tests__/fixtures/config-flows/features/core/navigation.yaml @@ -0,0 +1,5 @@ +appId: com.example.app +name: "navigation" +tags: ["core", "smoke"] +--- +- assertTrue: true diff --git a/packages/build-tools/src/utils/__integration-tests__/fixtures/config-flows/ignored-top-level.yaml b/packages/build-tools/src/utils/__integration-tests__/fixtures/config-flows/ignored-top-level.yaml new file mode 100644 index 0000000000..e92e7d3b49 --- /dev/null +++ b/packages/build-tools/src/utils/__integration-tests__/fixtures/config-flows/ignored-top-level.yaml @@ -0,0 +1,5 @@ +appId: com.example.app +name: "ignored-top-level" +tags: ["ignored"] +--- +- assertTrue: true diff --git a/packages/build-tools/src/utils/__integration-tests__/fixtures/config-flows/specs/api.spec.yaml b/packages/build-tools/src/utils/__integration-tests__/fixtures/config-flows/specs/api.spec.yaml new file mode 100644 index 0000000000..5b213d6076 --- /dev/null +++ b/packages/build-tools/src/utils/__integration-tests__/fixtures/config-flows/specs/api.spec.yaml @@ -0,0 +1,5 @@ +appId: com.example.app +name: "api.spec" +tags: ["api", "integration"] +--- +- assertTrue: true diff --git a/packages/build-tools/src/utils/__integration-tests__/fixtures/config-flows/specs/ui.spec.yaml b/packages/build-tools/src/utils/__integration-tests__/fixtures/config-flows/specs/ui.spec.yaml new file mode 100644 index 0000000000..3717e4d3be --- /dev/null +++ b/packages/build-tools/src/utils/__integration-tests__/fixtures/config-flows/specs/ui.spec.yaml @@ -0,0 +1,5 @@ +appId: com.example.app +name: "ui.spec" +tags: ["ui", "integration"] +--- +- assertTrue: true diff --git a/packages/build-tools/src/utils/__integration-tests__/fixtures/config-tags/config.yaml b/packages/build-tools/src/utils/__integration-tests__/fixtures/config-tags/config.yaml new file mode 100644 index 0000000000..c53c3f0b34 --- /dev/null +++ b/packages/build-tools/src/utils/__integration-tests__/fixtures/config-tags/config.yaml @@ -0,0 +1,7 @@ +flows: + - '*.yaml' +includeTags: + - cfgInclude +excludeTags: + - cfgExclude + diff --git a/packages/build-tools/src/utils/__integration-tests__/fixtures/config-tags/flow-cli-exclude.yaml b/packages/build-tools/src/utils/__integration-tests__/fixtures/config-tags/flow-cli-exclude.yaml new file mode 100644 index 0000000000..3ff62b173f --- /dev/null +++ b/packages/build-tools/src/utils/__integration-tests__/fixtures/config-tags/flow-cli-exclude.yaml @@ -0,0 +1,7 @@ +appId: com.example.app +name: flow-cli-exclude +tags: + - cliExclude +--- +- assertTrue: true + diff --git a/packages/build-tools/src/utils/__integration-tests__/fixtures/config-tags/flow-cli-include.yaml b/packages/build-tools/src/utils/__integration-tests__/fixtures/config-tags/flow-cli-include.yaml new file mode 100644 index 0000000000..3a8497357b --- /dev/null +++ b/packages/build-tools/src/utils/__integration-tests__/fixtures/config-tags/flow-cli-include.yaml @@ -0,0 +1,7 @@ +appId: com.example.app +name: flow-cli-include +tags: + - cliInclude +--- +- assertTrue: true + diff --git a/packages/build-tools/src/utils/__integration-tests__/fixtures/config-tags/flow-exc-only.yaml b/packages/build-tools/src/utils/__integration-tests__/fixtures/config-tags/flow-exc-only.yaml new file mode 100644 index 0000000000..aee9547b39 --- /dev/null +++ b/packages/build-tools/src/utils/__integration-tests__/fixtures/config-tags/flow-exc-only.yaml @@ -0,0 +1,7 @@ +appId: com.example.app +name: flow-exc-only +tags: + - cfgExclude +--- +- assertTrue: true + diff --git a/packages/build-tools/src/utils/__integration-tests__/fixtures/config-tags/flow-inc-and-exc.yaml b/packages/build-tools/src/utils/__integration-tests__/fixtures/config-tags/flow-inc-and-exc.yaml new file mode 100644 index 0000000000..111613b869 --- /dev/null +++ b/packages/build-tools/src/utils/__integration-tests__/fixtures/config-tags/flow-inc-and-exc.yaml @@ -0,0 +1,8 @@ +appId: com.example.app +name: flow-inc-and-exc +tags: + - cfgInclude + - cfgExclude +--- +- assertTrue: true + diff --git a/packages/build-tools/src/utils/__integration-tests__/fixtures/config-tags/flow-inc-only.yaml b/packages/build-tools/src/utils/__integration-tests__/fixtures/config-tags/flow-inc-only.yaml new file mode 100644 index 0000000000..c3db77dd00 --- /dev/null +++ b/packages/build-tools/src/utils/__integration-tests__/fixtures/config-tags/flow-inc-only.yaml @@ -0,0 +1,7 @@ +appId: com.example.app +name: flow-inc-only +tags: + - cfgInclude +--- +- assertTrue: true + diff --git a/packages/build-tools/src/utils/__integration-tests__/fixtures/config-tags/flow-neutral.yaml b/packages/build-tools/src/utils/__integration-tests__/fixtures/config-tags/flow-neutral.yaml new file mode 100644 index 0000000000..d7ec12369e --- /dev/null +++ b/packages/build-tools/src/utils/__integration-tests__/fixtures/config-tags/flow-neutral.yaml @@ -0,0 +1,7 @@ +appId: com.example.app +name: flow-neutral +tags: + - other +--- +- assertTrue: true + diff --git a/packages/build-tools/src/utils/__integration-tests__/fixtures/maestro-flows/basic-flow.yaml b/packages/build-tools/src/utils/__integration-tests__/fixtures/maestro-flows/basic-flow.yaml new file mode 100644 index 0000000000..a91f45687e --- /dev/null +++ b/packages/build-tools/src/utils/__integration-tests__/fixtures/maestro-flows/basic-flow.yaml @@ -0,0 +1,4 @@ +appId: com.example.app +name: "basic-flow" +--- +- assertTrue: true diff --git a/packages/build-tools/src/utils/__integration-tests__/fixtures/maestro-flows/invalid-flow.yaml b/packages/build-tools/src/utils/__integration-tests__/fixtures/maestro-flows/invalid-flow.yaml new file mode 100644 index 0000000000..9574307165 --- /dev/null +++ b/packages/build-tools/src/utils/__integration-tests__/fixtures/maestro-flows/invalid-flow.yaml @@ -0,0 +1,6 @@ +appId: com.example.app +name: "invalid-flow" +tags: + - invalid +--- +- assertTrue: true diff --git a/packages/build-tools/src/utils/__integration-tests__/fixtures/maestro-flows/no-tags-flow.yaml b/packages/build-tools/src/utils/__integration-tests__/fixtures/maestro-flows/no-tags-flow.yaml new file mode 100644 index 0000000000..2d1ce9d4ac --- /dev/null +++ b/packages/build-tools/src/utils/__integration-tests__/fixtures/maestro-flows/no-tags-flow.yaml @@ -0,0 +1,4 @@ +appId: com.example.app +name: "no-tags-flow" +--- +- assertTrue: true diff --git a/packages/build-tools/src/utils/__integration-tests__/fixtures/maestro-flows/non-flow-file.txt b/packages/build-tools/src/utils/__integration-tests__/fixtures/maestro-flows/non-flow-file.txt new file mode 100644 index 0000000000..403a38b6b8 --- /dev/null +++ b/packages/build-tools/src/utils/__integration-tests__/fixtures/maestro-flows/non-flow-file.txt @@ -0,0 +1 @@ +This is not a YAML file and should be ignored by flow discovery. \ No newline at end of file diff --git a/packages/build-tools/src/utils/__integration-tests__/fixtures/maestro-flows/subdir/nested-flow.yaml b/packages/build-tools/src/utils/__integration-tests__/fixtures/maestro-flows/subdir/nested-flow.yaml new file mode 100644 index 0000000000..4e296ac560 --- /dev/null +++ b/packages/build-tools/src/utils/__integration-tests__/fixtures/maestro-flows/subdir/nested-flow.yaml @@ -0,0 +1,7 @@ +appId: com.example.app +name: "nested-flow" +tags: + - nested + - auth +--- +- assertTrue: true diff --git a/packages/build-tools/src/utils/__integration-tests__/fixtures/maestro-flows/tagged-flow-auth.yaml b/packages/build-tools/src/utils/__integration-tests__/fixtures/maestro-flows/tagged-flow-auth.yaml new file mode 100644 index 0000000000..502a2f6155 --- /dev/null +++ b/packages/build-tools/src/utils/__integration-tests__/fixtures/maestro-flows/tagged-flow-auth.yaml @@ -0,0 +1,7 @@ +appId: com.example.app +name: "tagged-flow-auth" +tags: + - auth + - login +--- +- assertTrue: true diff --git a/packages/build-tools/src/utils/__integration-tests__/fixtures/maestro-flows/tagged-flow-regression.yaml b/packages/build-tools/src/utils/__integration-tests__/fixtures/maestro-flows/tagged-flow-regression.yaml new file mode 100644 index 0000000000..24fe42d924 --- /dev/null +++ b/packages/build-tools/src/utils/__integration-tests__/fixtures/maestro-flows/tagged-flow-regression.yaml @@ -0,0 +1,7 @@ +appId: com.example.app +name: "tagged-flow-regression" +tags: + - regression + - slow +--- +- assertTrue: true diff --git a/packages/build-tools/src/utils/__integration-tests__/fixtures/maestro-flows/tagged-flow-smoke.yaml b/packages/build-tools/src/utils/__integration-tests__/fixtures/maestro-flows/tagged-flow-smoke.yaml new file mode 100644 index 0000000000..225b3f2749 --- /dev/null +++ b/packages/build-tools/src/utils/__integration-tests__/fixtures/maestro-flows/tagged-flow-smoke.yaml @@ -0,0 +1,7 @@ +appId: com.example.app +name: "tagged-flow-smoke" +tags: + - smoke + - critical +--- +- assertTrue: true diff --git a/packages/build-tools/src/utils/__integration-tests__/fixtures/no-config-flows/subdir/nested-flow.yaml b/packages/build-tools/src/utils/__integration-tests__/fixtures/no-config-flows/subdir/nested-flow.yaml new file mode 100644 index 0000000000..846d6773ad --- /dev/null +++ b/packages/build-tools/src/utils/__integration-tests__/fixtures/no-config-flows/subdir/nested-flow.yaml @@ -0,0 +1,5 @@ +appId: com.example.app +name: "nested-flow" +tags: ["nested"] +--- +- assertTrue: true diff --git a/packages/build-tools/src/utils/__integration-tests__/fixtures/no-config-flows/top-level-flow.yaml b/packages/build-tools/src/utils/__integration-tests__/fixtures/no-config-flows/top-level-flow.yaml new file mode 100644 index 0000000000..cd56e64055 --- /dev/null +++ b/packages/build-tools/src/utils/__integration-tests__/fixtures/no-config-flows/top-level-flow.yaml @@ -0,0 +1,5 @@ +appId: com.example.app +name: "top-level-flow" +tags: ["basic"] +--- +- assertTrue: true diff --git a/packages/build-tools/src/utils/__integration-tests__/fixtures/single-file/standalone-flow.yaml b/packages/build-tools/src/utils/__integration-tests__/fixtures/single-file/standalone-flow.yaml new file mode 100644 index 0000000000..b8cce91534 --- /dev/null +++ b/packages/build-tools/src/utils/__integration-tests__/fixtures/single-file/standalone-flow.yaml @@ -0,0 +1,7 @@ +appId: com.example.app +name: "standalone-flow" +tags: + - standalone + - smoke +--- +- assertTrue: true diff --git a/packages/build-tools/src/utils/__tests__/artifacts.test.ts b/packages/build-tools/src/utils/__tests__/artifacts.test.ts new file mode 100644 index 0000000000..de3324f1c4 --- /dev/null +++ b/packages/build-tools/src/utils/__tests__/artifacts.test.ts @@ -0,0 +1,130 @@ +import { vol } from 'memfs'; +import fs from 'fs-extra'; + +import { findArtifacts } from '../artifacts'; + +jest.mock('fs'); + +describe(findArtifacts, () => { + beforeEach(async () => { + vol.reset(); + }); + + test('with correct path', async () => { + await fs.mkdirp('/dir1/dir2/dir3/dir4'); + await fs.writeFile('/dir1/dir2/dir3/dir4/file', new Uint8Array(Buffer.from('some content'))); + const loggerMock = { + info: jest.fn(), + error: jest.fn(), + }; + const paths = await findArtifacts({ + rootDir: '/dir1/dir2/dir3/dir4/', + patternOrPath: 'file', + logger: loggerMock as any, + }); + expect(loggerMock.error).toHaveBeenCalledTimes(0); + expect(paths.length).toBe(1); + expect(paths[0]).toBe('/dir1/dir2/dir3/dir4/file'); + }); + + test('with absolute path', async () => { + await fs.mkdirp('/Users/expo/build'); + await fs.mkdirp('/Users/expo/.maestro/tests'); + await fs.writeFile('/Users/expo/.maestro/tests/log', new Uint8Array(Buffer.from('some content'))); + const loggerMock = { + info: jest.fn(), + error: jest.fn(), + }; + const paths = await findArtifacts({ + rootDir: '/Users/expo/build', + patternOrPath: '/Users/expo/.maestro/tests', + logger: loggerMock as any, + }); + expect(loggerMock.error).toHaveBeenCalledTimes(0); + expect(paths.length).toBe(1); + expect(paths[0]).toBe('/Users/expo/.maestro/tests'); + }); + + test('with glob pattern', async () => { + await fs.mkdirp('/dir1/dir2/dir3/dir4'); + await fs.writeFile('/dir1/dir2/dir3/dir4/file.aab', new Uint8Array(Buffer.from('some content'))); + await fs.writeFile('/dir1/dir2/dir3/dir4/file-release.aab', new Uint8Array(Buffer.from('some content'))); + const loggerMock = { + info: jest.fn(), + error: jest.fn(), + }; + const paths = await findArtifacts({ + rootDir: '/dir1/dir2/dir3/dir4/', + patternOrPath: 'file{,-release}.aab', + logger: loggerMock as any, + }); + expect(loggerMock.error).toHaveBeenCalledTimes(0); + expect(paths.length).toBe(2); + }); + + test('with missing file in empty directory', async () => { + await fs.mkdirp('/dir1/dir2/dir3'); + let errMsg = ''; + const loggerMock = { + info: jest.fn(), + error: jest.fn().mockImplementation((msg) => { + errMsg = msg; + }), + }; + await expect( + findArtifacts({ + rootDir: '/dir1/dir2/dir3/dir4/', + patternOrPath: 'file', + logger: loggerMock as any, + }) + ).rejects.toThrow(); + expect(loggerMock.error).toHaveBeenCalledTimes(1); + expect(errMsg).toEqual( + 'There is no such file or directory "/dir1/dir2/dir3/dir4/file". Directory "/dir1/dir2/dir3" is empty.' + ); + }); + + test('with missing file in not empty directory', async () => { + await fs.mkdirp('/dir1/dir2/dir3/otherdir1'); + await fs.writeFile('/dir1/dir2/dir3/otherfile1', 'content'); + await fs.mkdirp('/dir1/dir2/dir3/otherdir2'); + let errMsg = ''; + const loggerMock = { + info: jest.fn(), + error: jest.fn().mockImplementation((msg) => { + errMsg = msg; + }), + }; + await expect( + findArtifacts({ + rootDir: '/dir1/dir2/dir3/dir4/', + patternOrPath: 'file', + logger: loggerMock as any, + }) + ).rejects.toThrow(); + expect(loggerMock.error).toHaveBeenCalledTimes(1); + expect(errMsg).toEqual( + 'There is no such file or directory "/dir1/dir2/dir3/dir4/file". Directory "/dir1/dir2/dir3" contains [otherdir1, otherdir2, otherfile1].' + ); + }); + + test('when checks up root directory', async () => { + await fs.mkdirp('/'); + let errMsg = ''; + const loggerMock = { + info: jest.fn(), + error: jest.fn().mockImplementation((msg) => { + errMsg = msg; + }), + }; + await expect( + findArtifacts({ + rootDir: '/dir1/dir2/dir3/dir4/', + patternOrPath: 'file', + logger: loggerMock as any, + }) + ).rejects.toThrow(); + expect(loggerMock.error).toHaveBeenCalledTimes(1); + expect(errMsg).toEqual('There is no such file or directory "/dir1/dir2/dir3/dir4/file".'); + }); +}); diff --git a/packages/build-tools/src/utils/__tests__/environmentSecrets.test.ts b/packages/build-tools/src/utils/__tests__/environmentSecrets.test.ts new file mode 100644 index 0000000000..0b9601a87e --- /dev/null +++ b/packages/build-tools/src/utils/__tests__/environmentSecrets.test.ts @@ -0,0 +1,37 @@ +import fs from 'fs/promises'; + +import { vol } from 'memfs'; + +import { createTemporaryEnvironmentSecretFile } from '../environmentSecrets'; + +describe(createTemporaryEnvironmentSecretFile, () => { + beforeEach(async () => { + vol.reset(); + }); + + it('does not expose the secret value in the file name', async () => { + await fs.mkdir('/test'); + const path = createTemporaryEnvironmentSecretFile({ + secretsDir: '/test', + name: 'GOOGLE_KEY_JSON', + contents_base64: Buffer.from('value', 'utf-8').toString('base64'), + }); + expect(path).not.toContain('GOOGLE_KEY_JSON'); + expect(path).not.toContain('value'); + }); + + it('does not produce shared files', async () => { + await fs.mkdir('/test'); + const path1 = createTemporaryEnvironmentSecretFile({ + secretsDir: '/test', + name: 'GOOGLE_KEY_JSON1', + contents_base64: Buffer.from('value', 'utf-8').toString('base64'), + }); + const path2 = createTemporaryEnvironmentSecretFile({ + secretsDir: '/test', + name: 'GOOGLE_KEY_JSON2', + contents_base64: Buffer.from('value', 'utf-8').toString('base64'), + }); + expect(path1).not.toEqual(path2); + }); +}); diff --git a/packages/build-tools/src/utils/__tests__/expoUpdates.test.ts b/packages/build-tools/src/utils/__tests__/expoUpdates.test.ts new file mode 100644 index 0000000000..adb5294924 --- /dev/null +++ b/packages/build-tools/src/utils/__tests__/expoUpdates.test.ts @@ -0,0 +1,107 @@ +import { Platform, BuildJob } from '@expo/eas-build-job'; + +import { BuildContext } from '../../context'; +import * as expoUpdates from '../expoUpdates'; +import getExpoUpdatesPackageVersionIfInstalledAsync from '../getExpoUpdatesPackageVersionIfInstalledAsync'; +import { iosSetChannelNativelyAsync } from '../../ios/expoUpdates'; +import { androidSetChannelNativelyAsync } from '../../android/expoUpdates'; + +jest.mock('../getExpoUpdatesPackageVersionIfInstalledAsync'); +jest.mock('../../ios/expoUpdates'); +jest.mock('../../android/expoUpdates'); +jest.mock('fs'); + +describe(expoUpdates.configureExpoUpdatesIfInstalledAsync, () => { + beforeAll(() => { + jest.restoreAllMocks(); + }); + + it('aborts if expo-updates is not installed', async () => { + jest.mocked(getExpoUpdatesPackageVersionIfInstalledAsync).mockResolvedValue(null); + + await expoUpdates.configureExpoUpdatesIfInstalledAsync( + { + job: { Platform: Platform.IOS }, + getReactNativeProjectDirectory: () => '/app', + } as any, + { resolvedRuntimeVersion: '1' } + ); + + expect(androidSetChannelNativelyAsync).not.toBeCalled(); + expect(iosSetChannelNativelyAsync).not.toBeCalled(); + expect(getExpoUpdatesPackageVersionIfInstalledAsync).toBeCalledTimes(1); + }); + + it('aborts if updates.url (app config) is set but updates.channel (eas.json) is not', async () => { + jest.mocked(getExpoUpdatesPackageVersionIfInstalledAsync).mockResolvedValue('0.18.0'); + + const managedCtx: BuildContext = { + appConfig: { + updates: { + url: 'https://u.expo.dev/blahblah', + }, + }, + job: { + platform: Platform.IOS, + }, + logger: { info: () => {} }, + getReactNativeProjectDirectory: () => '/app', + } as any; + await expoUpdates.configureExpoUpdatesIfInstalledAsync(managedCtx, { + resolvedRuntimeVersion: '1', + }); + + expect(androidSetChannelNativelyAsync).not.toBeCalled(); + expect(iosSetChannelNativelyAsync).not.toBeCalled(); + expect(getExpoUpdatesPackageVersionIfInstalledAsync).toBeCalledTimes(1); + }); + + it('configures for EAS if updates.channel (eas.json) and updates.url (app config) are set', async () => { + jest.mocked(getExpoUpdatesPackageVersionIfInstalledAsync).mockResolvedValue('0.18.0'); + + const managedCtx: BuildContext = { + appConfig: { + updates: { + url: 'https://u.expo.dev/blahblah', + }, + }, + job: { + updates: { + channel: 'main', + }, + platform: Platform.IOS, + }, + logger: { info: () => {} }, + getReactNativeProjectDirectory: () => '/app', + } as any; + await expoUpdates.configureExpoUpdatesIfInstalledAsync(managedCtx, { + resolvedRuntimeVersion: '1', + }); + + expect(androidSetChannelNativelyAsync).not.toBeCalled(); + expect(iosSetChannelNativelyAsync).toBeCalledTimes(1); + expect(getExpoUpdatesPackageVersionIfInstalledAsync).toBeCalledTimes(1); + }); + + it('configures for EAS if the updates.channel is set', async () => { + jest.mocked(getExpoUpdatesPackageVersionIfInstalledAsync).mockResolvedValue('0.18.0'); + + const managedCtx: BuildContext = { + appConfig: { + updates: { + url: 'https://u.expo.dev/blahblah', + }, + }, + job: { updates: { channel: 'main' }, platform: Platform.IOS }, + logger: { info: () => {} }, + getReactNativeProjectDirectory: () => '/app', + } as any; + await expoUpdates.configureExpoUpdatesIfInstalledAsync(managedCtx, { + resolvedRuntimeVersion: '1', + }); + + expect(androidSetChannelNativelyAsync).not.toBeCalled(); + expect(iosSetChannelNativelyAsync).toBeCalledTimes(1); + expect(getExpoUpdatesPackageVersionIfInstalledAsync).toBeCalledTimes(1); + }); +}); diff --git a/packages/build-tools/src/utils/__tests__/files.test.ts b/packages/build-tools/src/utils/__tests__/files.test.ts new file mode 100644 index 0000000000..d850ad5517 --- /dev/null +++ b/packages/build-tools/src/utils/__tests__/files.test.ts @@ -0,0 +1,57 @@ +import { vol } from 'memfs'; + +import { decompressTarAsync, isFileTarGzAsync } from '../files'; + +// contains a 'hello.txt' file with 'hello' content +const HELLO_TAR_GZ_BUFFER = Buffer.from( + 'H4sIACyzHGgAA9PTZ6A5MDAwMDc1VQDTZhDawMgEQkOBgqEpUNLQ2NDExFTBwNDI2NCUQcGU9k5jYCgtLkksAjqlOCs5IzczNScRhzqgsrQ0POZA/QGnhwjQ0w9ydXTxddXLTaGZHcDwMDMxwRf/Zoj4NzIHxr+xqbkBg4IBzVyEBEZ4/Gek5uTkcw20K0bBQAE9fXAK0ANmAr30KtrYQSD/G2KW/yamxmaj+Z8eQL6bg0F1s0wGA/Pbqd58TQYCbbu/idg6zrRju6a2qeQx76pFHQouJal7dod2rnfp7WyVeH77TNMy2R+n/igl3GNs4Jg4P6ti3YIzO80KOt8tvHJJ4onC1GknLH7l927M3bsx+7rXXN9LSROffDtptT3HrHqj/2e56b+UOxJEvrMfjhAJzk/f8V5Q6vOp+oqv65Wexyez9DRv78v7Ufw/c9Lz1PWv9TrWCTSuXXUrn6PG5P9dnYL/+51m/V974Yf+qY9K/jaxzf1/Xif/kw5R/JnfXP1/6lydtzaCkbHr9pUf+/3nOv8/ZYYlf/Kb7847NF+B45G8JQPT5FmMDEIDHd6DDejpJxYUFNO2EUhS+w/YFgDW/0amo+0/ugBo/OfmJ2XmpNIoGZAU/8aQ8t/IbDT+6QFQ478gMTk7MT1VL6s4P496dhBu/xujxb+5gYHpaP1PD1BdO9r4HwWjYBSMgpEIANczH2UAFg' + + Array.from({ length: 652 }, () => 'A') + + '==', + 'base64' +); + +describe('isFileTarGzAsync', () => { + it('should return true for a tar.gz-named file', async () => { + vol.fromJSON({ + 'test.tar.gz': 'whatever', + }); + + const result = await isFileTarGzAsync('test.tar.gz'); + expect(result).toBe(true); + }); + + it('should return true for a tar.gz file', async () => { + vol.fromJSON({ + test: HELLO_TAR_GZ_BUFFER, + }); + + const result = await isFileTarGzAsync('test'); + expect(result).toBe(true); + }); + + it('should return false for a non-tar.gz file', async () => { + vol.fromJSON({ + 'test.txt': 'Hello, world!', + }); + + const result = await isFileTarGzAsync('test.txt'); + expect(result).toBe(false); + }); +}); + +describe('decompressTarAsync', () => { + it('should decompress a tar.gz file', async () => { + vol.fromNestedJSON({ + 'test.tar.gz': HELLO_TAR_GZ_BUFFER, + test: {}, + }); + + await decompressTarAsync({ + archivePath: 'test.tar.gz', + destinationDirectory: 'test', + }); + + expect(vol.readFileSync('test/README.md', 'utf8')).toBe('hello\n'); + expect(vol.readFileSync('test/apps/mobile/package.json', 'utf8')).toBe('{}\n'); + }); +}); diff --git a/packages/build-tools/src/utils/__tests__/hooks.test.ts b/packages/build-tools/src/utils/__tests__/hooks.test.ts new file mode 100644 index 0000000000..ba0da95d27 --- /dev/null +++ b/packages/build-tools/src/utils/__tests__/hooks.test.ts @@ -0,0 +1,154 @@ +import { BuildJob } from '@expo/eas-build-job'; +import spawn from '@expo/turtle-spawn'; +import { vol } from 'memfs'; + +import { BuildContext } from '../../context'; +import { Hook, runHookIfPresent } from '../hooks'; +import { PackageManager } from '../packageManager'; + +jest.mock('fs'); +jest.mock('@expo/turtle-spawn', () => ({ + __esModule: true, + default: jest.fn(), +})); + +const loggerMock = { + info: jest.fn(), + error: jest.fn(), + warn: jest.fn(), + child: () => loggerMock, +}; + +let ctx: BuildContext; + +describe(runHookIfPresent, () => { + beforeEach(() => { + vol.reset(); + (spawn as jest.Mock).mockReset(); + + ctx = new BuildContext({ projectRootDirectory: '.', platform: 'android' } as BuildJob, { + workingdir: '/workingdir', + logBuffer: { getLogs: () => [], getPhaseLogs: () => [] }, + logger: loggerMock as any, + env: { + __API_SERVER_URL: 'http://api.expo.test', + }, + uploadArtifact: jest.fn(), + }); + }); + + it('runs the hook if present in package.json', async () => { + vol.fromJSON( + { + './package.json': JSON.stringify({ + scripts: { + [Hook.PRE_INSTALL]: 'echo pre_install', + [Hook.POST_INSTALL]: 'echo post_install', + [Hook.PRE_UPLOAD_ARTIFACTS]: 'echo pre_upload_artifacts', + }, + }), + }, + '/workingdir/build' + ); + + await runHookIfPresent(ctx, Hook.PRE_INSTALL); + + expect(spawn).toBeCalledWith(PackageManager.NPM, ['run', Hook.PRE_INSTALL], expect.anything()); + }); + + it('runs the hook with npm even if yarn.lock exists', async () => { + vol.fromJSON( + { + './package.json': JSON.stringify({ + scripts: { + [Hook.PRE_INSTALL]: 'echo pre_install', + [Hook.POST_INSTALL]: 'echo post_install', + [Hook.PRE_UPLOAD_ARTIFACTS]: 'echo pre_upload_artifacts', + }, + }), + './yarn.lock': 'fakelockfile', + }, + '/workingdir/build' + ); + + await runHookIfPresent(ctx, Hook.PRE_INSTALL); + + expect(spawn).toBeCalledWith(PackageManager.NPM, ['run', Hook.PRE_INSTALL], expect.anything()); + }); + + it('runs the PRE_INSTALL hook using npm when the project uses yarn 2', async () => { + vol.fromJSON( + { + './package.json': JSON.stringify({ + scripts: { + [Hook.PRE_INSTALL]: 'echo pre_install', + [Hook.POST_INSTALL]: 'echo post_install', + [Hook.PRE_UPLOAD_ARTIFACTS]: 'echo pre_upload_artifacts', + }, + }), + './yarn.lock': 'fakelockfile', + './.yarnrc.yml': 'fakeyarn2config', + }, + '/workingdir/build' + ); + + await runHookIfPresent(ctx, Hook.PRE_INSTALL); + + expect(spawn).toBeCalledWith(PackageManager.NPM, ['run', Hook.PRE_INSTALL], expect.anything()); + expect(true).toBe(true); + }); + + it('does not run the hook if not present in package.json', async () => { + vol.fromJSON( + { + './package.json': JSON.stringify({ + scripts: { + [Hook.POST_INSTALL]: 'echo post_install', + [Hook.PRE_UPLOAD_ARTIFACTS]: 'echo pre_upload_artifacts', + }, + }), + }, + '/workingdir/build' + ); + + await runHookIfPresent(ctx, Hook.PRE_INSTALL); + + expect(spawn).not.toBeCalled(); + }); + + it('runs ON_BUILD_CANCEL hook if present', async () => { + vol.fromJSON( + { + './package.json': JSON.stringify({ + scripts: { + [Hook.ON_BUILD_CANCEL]: 'echo build_cancel', + }, + }), + }, + '/workingdir/build' + ); + + await runHookIfPresent(ctx, Hook.ON_BUILD_CANCEL); + + expect(spawn).toBeCalledWith( + ctx.packageManager, + ['run', 'eas-build-on-cancel'], + expect.anything() + ); + }); + + it('does not run ON_BUILD_CANCEL hook if not present', async () => { + vol.fromJSON( + { + './package.json': JSON.stringify({ + scripts: {}, + }), + }, + '/workingdir/build' + ); + + await runHookIfPresent(ctx, Hook.ON_BUILD_CANCEL); + + expect(spawn).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/build-tools/src/utils/__tests__/outputs.test.ts b/packages/build-tools/src/utils/__tests__/outputs.test.ts new file mode 100644 index 0000000000..f66d2bedfa --- /dev/null +++ b/packages/build-tools/src/utils/__tests__/outputs.test.ts @@ -0,0 +1,333 @@ +import { randomBytes, randomUUID } from 'crypto'; + +import { JobInterpolationContext } from '@expo/eas-build-job'; +import { + BuildRuntimePlatform, + BuildStep, + BuildStepGlobalContext, + BuildStepOutput, +} from '@expo/steps'; +import { createLogger } from '@expo/logger'; +import fetch, { Response } from 'node-fetch'; + +import { collectJobOutputs, uploadJobOutputsToWwwAsync } from '../outputs'; +import { TurtleFetchError } from '../turtleFetch'; + +jest.mock('node-fetch'); + +const workflowJobId = randomUUID(); +const robotAccessToken = randomBytes(32).toString('hex'); + +const env = { + __WORKFLOW_JOB_ID: workflowJobId, +}; + +const context = new BuildStepGlobalContext( + { + buildLogsDirectory: 'test', + projectSourceDirectory: 'test', + projectTargetDirectory: 'test', + defaultWorkingDirectory: 'test', + runtimePlatform: BuildRuntimePlatform.DARWIN, + staticContext: () => ({ + job: { + outputs: { + fingerprintHash: '${{ steps.setup.outputs.fingerprint_hash }}', + nodeVersion: '${{ steps.node_setup.outputs.node_version }}', + }, + secrets: { + robotAccessToken, + }, + } as any, + metadata: {} as any, + env, + expoApiServerURL: 'https://api.expo.test', + }), + env, + logger: createLogger({ name: 'test' }), + updateEnv: () => {}, + }, + false +); + +const fingerprintHashStepOutput = new BuildStepOutput(context, { + id: 'fingerprint_hash', + stepDisplayName: 'test', + required: true, +}); +fingerprintHashStepOutput.set('mock-fingerprint-hash'); + +const unusedStepOutput = new BuildStepOutput(context, { + id: 'test3', + stepDisplayName: 'test', + required: false, +}); + +context.registerStep( + new BuildStep(context, { + id: 'setup', + displayName: 'test', + command: 'test', + outputs: [fingerprintHashStepOutput, unusedStepOutput], + }) +); + +const nodeVersionStepOutput = new BuildStepOutput(context, { + id: 'node_version', + stepDisplayName: 'test2', + required: false, +}); + +context.registerStep( + new BuildStep(context, { + id: 'node_setup', + displayName: 'test2', + command: 'test2', + outputs: [nodeVersionStepOutput], + }) +); + +const interpolationContext: JobInterpolationContext = { + ...context.staticContext, + env: {}, + always: () => true, + never: () => false, + success: () => !context.hasAnyPreviousStepFailed, + failure: () => context.hasAnyPreviousStepFailed, + fromJSON: (json: string) => JSON.parse(json), + toJSON: (value: unknown) => JSON.stringify(value), + contains: (value: string, substring: string) => value.includes(substring), + startsWith: (value: string, prefix: string) => value.startsWith(prefix), + endsWith: (value: string, suffix: string) => value.endsWith(suffix), + hashFiles: (...value: string[]) => value.join(','), + replaceAll: (input: string, stringToReplace: string, replacementString: string) => { + while (input.includes(stringToReplace)) { + input = input.replace(stringToReplace, replacementString); + } + return input; + }, + substring: (input: string, start: number, end?: number) => input.substring(start, end), +}; + +describe(collectJobOutputs, () => { + it('returns empty object for outputs of a step with no outputs', () => { + expect( + collectJobOutputs({ + jobOutputDefinitions: {}, + interpolationContext, + }) + ).toEqual({}); + }); + + it('interpolates outputs', () => { + expect( + collectJobOutputs({ + jobOutputDefinitions: { + test: '${{ 1 + 1 }}', + substring: '${{ substring("hello", 1, 3) }}', + }, + interpolationContext, + }) + ).toEqual({ test: '2', substring: 'el' }); + + expect( + collectJobOutputs({ + jobOutputDefinitions: { + fingerprint_hash: '${{ steps.setup.outputs.fingerprint_hash }}', + }, + interpolationContext, + }) + ).toEqual({ fingerprint_hash: 'mock-fingerprint-hash' }); + }); + + it('defaults missing values to empty string', () => { + expect( + collectJobOutputs({ + jobOutputDefinitions: { + missing_output: '${{ steps.setup.outputs.missing_output }}', + not_set_output: '${{ steps.setup.outputs.test_3 }}', + }, + interpolationContext, + }) + ).toEqual({ missing_output: '', not_set_output: '' }); + }); + + it('interpolates hashFiles function', () => { + const mockHash = 'mockhash'; + const contextWithHash: JobInterpolationContext = { + ...interpolationContext, + hashFiles: jest.fn(() => mockHash), + }; + + expect( + collectJobOutputs({ + jobOutputDefinitions: { + file_hash: '${{ hashFiles("package.json") }}', + }, + interpolationContext: contextWithHash, + }) + ).toEqual({ file_hash: mockHash }); + + expect(contextWithHash.hashFiles).toHaveBeenCalledWith('package.json'); + }); + + it('interpolates hashFiles with multiple patterns', () => { + const mockHash = 'mockhash'; + const contextWithHash: JobInterpolationContext = { + ...interpolationContext, + hashFiles: jest.fn(() => mockHash), + }; + + expect( + collectJobOutputs({ + jobOutputDefinitions: { + combined: 'key-${{ hashFiles("*.lock") }}-v1', + }, + interpolationContext: contextWithHash, + }) + ).toEqual({ combined: `key-${mockHash}-v1` }); + + expect(contextWithHash.hashFiles).toHaveBeenCalledWith('*.lock'); + + const contextWithMultiPattern: JobInterpolationContext = { + ...interpolationContext, + hashFiles: jest.fn(() => mockHash), + }; + + expect( + collectJobOutputs({ + jobOutputDefinitions: { + multi_pattern: '${{ hashFiles("**/package-lock.json", "**/Gemfile.lock") }}', + }, + interpolationContext: contextWithMultiPattern, + }) + ).toEqual({ multi_pattern: mockHash }); + + expect(contextWithMultiPattern.hashFiles).toHaveBeenCalledWith( + '**/package-lock.json', + '**/Gemfile.lock' + ); + }); + + it('handles hashFiles with empty result', () => { + const contextWithEmptyHash: JobInterpolationContext = { + ...interpolationContext, + hashFiles: () => '', + }; + + expect( + collectJobOutputs({ + jobOutputDefinitions: { + cache_key: 'prefix-${{ hashFiles("nonexistent.txt") }}-suffix', + }, + interpolationContext: contextWithEmptyHash, + }) + ).toEqual({ cache_key: 'prefix--suffix' }); + }); +}); + +describe(uploadJobOutputsToWwwAsync, () => { + it('uploads outputs', async () => { + const logger = createLogger({ name: 'test' }).child('test'); + + const fetchMock = jest.mocked(fetch); + fetchMock.mockResolvedValue({ + ok: true, + status: 200, + statusText: 'OK', + } as unknown as Response); + await uploadJobOutputsToWwwAsync(context, { + logger, + expoApiV2BaseUrl: 'http://exp.test/--/api/v2/', + }); + expect(fetchMock).toHaveBeenCalledWith( + `http://exp.test/--/api/v2/workflows/${workflowJobId}`, // URL + expect.objectContaining({ + method: 'PATCH', + headers: { + Authorization: `Bearer ${robotAccessToken}`, + 'Content-Type': 'application/json', + }, + timeout: 20000, + body: JSON.stringify({ + outputs: { fingerprintHash: 'mock-fingerprint-hash', nodeVersion: '' }, + }), + }) + ); + }); + + it('outputs upload fails, succeeds on retry', async () => { + const logger = createLogger({ name: 'test' }).child('test'); + + const fetchMock = jest.mocked(fetch); + fetchMock.mockResolvedValueOnce({ + ok: false, + status: 400, + statusText: 'Request failed', + } as unknown as Response); + fetchMock.mockResolvedValueOnce({ + ok: true, + status: 200, + statusText: 'OK', + } as unknown as Response); + await uploadJobOutputsToWwwAsync(context, { + logger, + expoApiV2BaseUrl: 'http://exp.test/--/api/v2/', + }); + expect(fetchMock).toHaveBeenCalledWith( + `http://exp.test/--/api/v2/workflows/${workflowJobId}`, // URL + expect.objectContaining({ + method: 'PATCH', + headers: { + Authorization: `Bearer ${robotAccessToken}`, + 'Content-Type': 'application/json', + }, + timeout: 20000, + body: JSON.stringify({ + outputs: { fingerprintHash: 'mock-fingerprint-hash', nodeVersion: '' }, + }), + }) + ); + }); + + it('outputs upload fails', async () => { + const logger = createLogger({ name: 'test' }).child('test'); + + const loggerErrorSpy = jest.spyOn(logger, 'error'); + const fetchMock = jest.mocked(fetch); + const expectedFetchResponse = { + ok: false, + status: 400, + statusText: 'Request failed', + } as unknown as Response; + fetchMock.mockResolvedValue(expectedFetchResponse); + const expectedThrownError = new TurtleFetchError( + 'Request failed with status 400', + expectedFetchResponse + ); + await expect( + uploadJobOutputsToWwwAsync(context, { + logger, + expoApiV2BaseUrl: 'http://exp.test/--/api/v2/', + }) + ).rejects.toThrow(expectedThrownError); + expect(fetchMock).toHaveBeenCalledWith( + `http://exp.test/--/api/v2/workflows/${workflowJobId}`, // URL + expect.objectContaining({ + method: 'PATCH', + headers: { + Authorization: `Bearer ${robotAccessToken}`, + 'Content-Type': 'application/json', + }, + timeout: 20000, + body: JSON.stringify({ + outputs: { fingerprintHash: 'mock-fingerprint-hash', nodeVersion: '' }, + }), + }) + ); + expect(loggerErrorSpy).toHaveBeenCalledWith( + { err: expectedThrownError }, + 'Failed to upload outputs' + ); + }); +}); diff --git a/packages/build-tools/src/utils/__tests__/packageManager.test.ts b/packages/build-tools/src/utils/__tests__/packageManager.test.ts new file mode 100644 index 0000000000..ead55766a5 --- /dev/null +++ b/packages/build-tools/src/utils/__tests__/packageManager.test.ts @@ -0,0 +1,231 @@ +import path from 'path'; + +import { vol } from 'memfs'; +import fs from 'fs-extra'; + +import { + resolvePackageManager, + findPackagerRootDir, + getPackageVersionFromPackageJson, + shouldUseFrozenLockfile, +} from '../packageManager'; + +jest.mock('fs'); + +const rootDir = '/working/dir'; + +describe(resolvePackageManager, () => { + beforeEach(async () => { + vol.reset(); + await fs.mkdirp(rootDir); + }); + + it('returns yarn when no lockfiles exist', async () => { + expect(resolvePackageManager(rootDir)).toBe('yarn'); + }); + + it('returns npm when only package-json.lock exist', async () => { + await fs.writeFile(path.join(rootDir, 'package-lock.json'), 'content'); + expect(resolvePackageManager(rootDir)).toBe('npm'); + }); + + it('returns yarn when only yarn.lock exists', async () => { + await fs.writeFile(path.join(rootDir, 'yarn.lock'), 'content'); + expect(resolvePackageManager(rootDir)).toBe('yarn'); + }); + + it('returns yarn when both lockfiles exists', async () => { + await fs.writeFile(path.join(rootDir, 'yarn.lock'), 'content'); + await fs.writeFile(path.join(rootDir, 'package-lock.json'), 'content'); + expect(resolvePackageManager(rootDir)).toBe('yarn'); + }); + + it('returns npm within a monorepo', async () => { + await fs.writeFile(path.join(rootDir, 'package-lock.json'), 'content'); + await fs.writeJson(path.join(rootDir, 'package.json'), { + name: 'monorepo', + workspaces: ['packages/*'], + }); + + const nestedDir = path.join(rootDir, 'packages', 'expo-app'); + await fs.mkdirp(nestedDir); + await fs.writeJson(path.join(nestedDir, 'package.json'), { + name: '@monorepo/expo-app', + }); + + expect(resolvePackageManager(nestedDir)).toBe('npm'); + }); + + it('returns yarn with an invalid monorepo', async () => { + // this shouldn't be picked up, because our package.json doesn't define the workspace + await fs.writeFile(path.join(rootDir, 'package-lock.json'), 'content'); + await fs.writeFile(path.join(rootDir, 'package.json'), 'invalidjson'); + + const nestedDir = path.join(rootDir, 'packages', 'expo-app'); + await fs.mkdirp(nestedDir); + await fs.writeFile(path.join(nestedDir, 'package.json'), 'content'); + + expect(resolvePackageManager(nestedDir)).toBe('yarn'); + }); +}); + +describe(findPackagerRootDir, () => { + beforeEach(() => { + vol.reset(); + }); + + it('returns the workspace root if the current dir is a workspace', async () => { + vol.fromJSON( + { + './package.json': JSON.stringify({ + workspaces: ['some-package', 'react-native-project'], + }), + './some-package/package.json': JSON.stringify({ + name: 'some-package', + }), + './react-native-project/package.json': JSON.stringify({ + name: 'react-native-project', + }), + }, + '/repo' + ); + + const rootDir = findPackagerRootDir('/repo/react-native-project'); + expect(rootDir).toBe('/repo'); + }); + + it( + `returns the current dir if it's not a workspace` + + ` (package.json exists in root dir and contains workspaces field)`, + async () => { + vol.fromJSON( + { + './package.json': JSON.stringify({ + workspaces: ['some-package'], + }), + './some-package/package.json': JSON.stringify({ + name: 'some-package', + }), + './react-native-project/package.json': JSON.stringify({ + name: 'react-native-project', + }), + }, + '/repo' + ); + + const rootDir = findPackagerRootDir('/repo/react-native-project'); + expect(rootDir).toBe('/repo/react-native-project'); + } + ); + + it( + `returns the current dir if it's not a workspace` + + ` (package.json exists in root dir and does not contain workspaces field)`, + async () => { + vol.fromJSON( + { + './package.json': JSON.stringify({}), + './some-package/package.json': JSON.stringify({ + name: 'some-package', + }), + './react-native-project/package.json': JSON.stringify({ + name: 'react-native-project', + }), + }, + '/repo' + ); + + const rootDir = findPackagerRootDir('/repo/react-native-project'); + expect(rootDir).toBe('/repo/react-native-project'); + } + ); + + it(`returns the current dir if it's not a workspace (package.json does not exist in root dir) `, async () => { + vol.fromJSON( + { + './some-package/package.json': JSON.stringify({ + name: 'some-package', + }), + './react-native-project/package.json': JSON.stringify({ + name: 'react-native-project', + }), + }, + '/repo' + ); + + const rootDir = findPackagerRootDir('/repo/react-native-project'); + expect(rootDir).toBe('/repo/react-native-project'); + }); +}); + +describe(getPackageVersionFromPackageJson, () => { + const CASES = [ + [ + { + dependencies: { + 'react-native': '0.79.0', + }, + }, + 'react-native', + '0.79.0', + ], + [ + { + dependencies: { + expo: '52.0.0', + 'react-native': '0.79.0', + }, + }, + 'expo', + '52.0.0', + ], + [ + { + dependencies: { + 'react-native': '~0.79.0', + }, + }, + 'react-native', + '0.79.0', + ], + [null, 'react-native', undefined], + ['not-a-package-json', 'react-native', undefined], + [42, 'react-native', undefined], + ] as const; + + for (const [packageJson, packageName, expectedVersion] of CASES) { + it(`returns the version of the package ${packageName}`, () => { + expect(getPackageVersionFromPackageJson({ packageJson, packageName })).toBe(expectedVersion); + }); + } +}); + +describe(shouldUseFrozenLockfile, () => { + it('works', () => { + expect( + shouldUseFrozenLockfile({ + env: {}, + sdkVersion: '52.0.0', + reactNativeVersion: '0.79.0', + }) + ).toBe(false); + + expect( + shouldUseFrozenLockfile({ + env: {}, + sdkVersion: '53.0.0', + reactNativeVersion: '0.79.0', + }) + ).toBe(true); + + expect( + shouldUseFrozenLockfile({ + env: { + EAS_NO_FROZEN_LOCKFILE: '1', + }, + sdkVersion: '53.0.0', + reactNativeVersion: '0.79.0', + }) + ).toBe(false); + }); +}); diff --git a/packages/build-tools/src/utils/__tests__/project.test.ts b/packages/build-tools/src/utils/__tests__/project.test.ts new file mode 100644 index 0000000000..8f0e4930ec --- /dev/null +++ b/packages/build-tools/src/utils/__tests__/project.test.ts @@ -0,0 +1,73 @@ +import { ExpoConfig } from '@expo/config'; +import { Android } from '@expo/eas-build-job'; +import spawn from '@expo/turtle-spawn'; +import { instance, mock, when } from 'ts-mockito'; + +import { BuildContext } from '../../context'; +import { PackageManager } from '../packageManager'; +import { runExpoCliCommand } from '../project'; + +jest.mock('@expo/turtle-spawn', () => ({ + __esModule: true, + default: jest.fn(), +})); + +describe(runExpoCliCommand, () => { + describe('Expo SDK >= 46', () => { + it('spawns expo via "npx" when package manager is npm', () => { + const mockExpoConfig = mock(); + when(mockExpoConfig.sdkVersion).thenReturn('46.0.0'); + const expoConfig = instance(mockExpoConfig); + + const mockCtx = mock>(); + when(mockCtx.packageManager).thenReturn(PackageManager.NPM); + when(mockCtx.appConfig).thenReturn(expoConfig); + const ctx = instance(mockCtx); + + void runExpoCliCommand({ args: ['doctor'], options: {}, packageManager: ctx.packageManager }); + expect(spawn).toHaveBeenCalledWith('npx', ['expo', 'doctor'], expect.any(Object)); + }); + + it('spawns expo via "yarn" when package manager is yarn', () => { + const mockExpoConfig = mock(); + when(mockExpoConfig.sdkVersion).thenReturn('46.0.0'); + const expoConfig = instance(mockExpoConfig); + + const mockCtx = mock>(); + when(mockCtx.packageManager).thenReturn(PackageManager.NPM); + when(mockCtx.appConfig).thenReturn(expoConfig); + const ctx = instance(mockCtx); + + void runExpoCliCommand({ args: ['doctor'], options: {}, packageManager: ctx.packageManager }); + expect(spawn).toHaveBeenCalledWith('npx', ['expo', 'doctor'], expect.any(Object)); + }); + + it('spawns expo via "pnpm" when package manager is pnpm', () => { + const mockExpoConfig = mock(); + when(mockExpoConfig.sdkVersion).thenReturn('46.0.0'); + const expoConfig = instance(mockExpoConfig); + + const mockCtx = mock>(); + when(mockCtx.packageManager).thenReturn(PackageManager.PNPM); + when(mockCtx.appConfig).thenReturn(expoConfig); + const ctx = instance(mockCtx); + + void runExpoCliCommand({ args: ['doctor'], options: {}, packageManager: ctx.packageManager }); + expect(spawn).toHaveBeenCalledWith('pnpm', ['expo', 'doctor'], expect.any(Object)); + }); + + it('spawns expo via "bun" when package manager is bun', () => { + const mockExpoConfig = mock(); + when(mockExpoConfig.sdkVersion).thenReturn('46.0.0'); + const expoConfig = instance(mockExpoConfig); + + const mockCtx = mock>(); + when(mockCtx.packageManager).thenReturn(PackageManager.BUN); + when(mockCtx.appConfig).thenReturn(expoConfig); + const ctx = instance(mockCtx); + + void runExpoCliCommand({ args: ['doctor'], options: {}, packageManager: ctx.packageManager }); + expect(spawn).toHaveBeenCalledWith('bun', ['expo', 'doctor'], expect.any(Object)); + }); + }); +}); diff --git a/packages/build-tools/src/utils/appConfig.ts b/packages/build-tools/src/utils/appConfig.ts new file mode 100644 index 0000000000..46c1862adf --- /dev/null +++ b/packages/build-tools/src/utils/appConfig.ts @@ -0,0 +1,60 @@ +import { getConfig, ProjectConfig } from '@expo/config'; +import { Env } from '@expo/eas-build-job'; +import { bunyan, LoggerLevel } from '@expo/logger'; +import { load } from '@expo/env'; +import semver from 'semver'; + +export function readAppConfig({ + projectDir, + env, + logger, + sdkVersion, +}: { + projectDir: string; + env: Env; + logger: bunyan; + sdkVersion?: string; +}): ProjectConfig { + const originalProcessExit = process.exit; + const originalProcessCwd = process.cwd; + const originalStdoutWrite = process.stdout.write; + const originalStderrWrite = process.stderr.write; + const originalProcessEnv = process.env; + + const stdoutStore: { text: string; level: LoggerLevel }[] = []; + const shouldLoadEnvVarsFromDotenvFile = sdkVersion && semver.satisfies(sdkVersion, '>=49'); + const envVarsFromDotenvFile = shouldLoadEnvVarsFromDotenvFile ? load(projectDir) : {}; + const newEnvsToUse = { ...env, ...envVarsFromDotenvFile }; + try { + process.env = newEnvsToUse; + process.exit = () => { + throw new Error('Failed to evaluate app config file'); + }; + process.cwd = () => projectDir; + process.stdout.write = function (...args: any) { + stdoutStore.push({ text: String(args[0]), level: LoggerLevel.INFO }); + return originalStdoutWrite.apply(process.stdout, args); + }; + process.stderr.write = function (...args: any) { + stdoutStore.push({ text: String(args[0]), level: LoggerLevel.ERROR }); + return originalStderrWrite.apply(process.stderr, args); + }; + return getConfig(projectDir, { + skipSDKVersionRequirement: true, + isPublicConfig: true, + }); + } catch (err) { + process.stdout.write = originalStdoutWrite; + process.stderr.write = originalStderrWrite; + stdoutStore.forEach(({ text, level }) => { + logger[level](text.trim()); + }); + throw err; + } finally { + process.env = originalProcessEnv; + process.exit = originalProcessExit; + process.cwd = originalProcessCwd; + process.stdout.write = originalStdoutWrite; + process.stderr.write = originalStderrWrite; + } +} diff --git a/packages/build-tools/src/utils/artifacts.ts b/packages/build-tools/src/utils/artifacts.ts new file mode 100644 index 0000000000..d7544565e8 --- /dev/null +++ b/packages/build-tools/src/utils/artifacts.ts @@ -0,0 +1,206 @@ +import path from 'path'; + +import fs from 'fs-extra'; +import fg from 'fast-glob'; +import { bunyan } from '@expo/logger'; +import { ManagedArtifactType, Job, BuildJob } from '@expo/eas-build-job'; +import promiseLimit from 'promise-limit'; + +import { BuildContext } from '../context'; + +export class FindArtifactsError extends Error {} + +export async function findArtifacts({ + rootDir, + patternOrPath, + logger, +}: { + rootDir: string; + patternOrPath: string; + /** If provided, will log error suggesting possible files to upload. */ + logger: bunyan | null; +}): Promise { + const files = path.isAbsolute(patternOrPath) + ? (await fs.pathExists(patternOrPath)) + ? [patternOrPath] + : [] + : await fg(patternOrPath, { cwd: rootDir, onlyFiles: false }); + if (files.length === 0) { + if (fg.isDynamicPattern(patternOrPath)) { + throw new FindArtifactsError(`There are no files matching pattern "${patternOrPath}"`); + } else { + if (logger) { + await logMissingFileError(path.join(rootDir, patternOrPath), logger); + } + throw new FindArtifactsError(`No such file or directory ${patternOrPath}`); + } + } + + return files.map((filePath) => { + // User may provide an absolute path as input in which case + // fg will return an absolute path. + if (path.isAbsolute(filePath)) { + return filePath; + } + + // User may also provide a relative path in which case + // fg will return a path relative to rootDir. + return path.join(rootDir, filePath); + }); +} + +async function logMissingFileError(artifactPath: string, buildLogger: bunyan): Promise { + let currentPath = artifactPath; + while (!(await fs.pathExists(currentPath))) { + currentPath = path.resolve(currentPath, '..'); + } + if (currentPath === path.resolve(currentPath, '..')) { + buildLogger.error(`There is no such file or directory "${artifactPath}".`); + return; + } + const dirContent = await fs.readdir(currentPath); + if (dirContent.length === 0) { + buildLogger.error( + `There is no such file or directory "${artifactPath}". Directory "${currentPath}" is empty.` + ); + } else { + buildLogger.error( + `There is no such file or directory "${artifactPath}". Directory "${currentPath}" contains [${dirContent.join( + ', ' + )}].` + ); + } +} + +export async function maybeFindAndUploadBuildArtifacts( + ctx: BuildContext, + { logger }: { logger: bunyan } +): Promise { + if (!ctx.job.buildArtifactPaths || ctx.job.buildArtifactPaths.length === 0) { + return; + } + try { + const buildArtifacts = ( + await Promise.all( + ctx.job.buildArtifactPaths.map((path) => + findArtifacts({ + rootDir: ctx.getReactNativeProjectDirectory(), + patternOrPath: path, + logger, + }) + ) + ) + ).flat(); + const artifactsSizes = await getArtifactsSizes(buildArtifacts); + logger.info(`Build artifacts:`); + for (const artifactPath of buildArtifacts) { + const maybeSize = artifactsSizes[artifactPath]; + logger.info(` - ${artifactPath}${maybeSize ? ` (${formatBytes(maybeSize)})` : ''}`); + } + logger.info('Uploading build artifacts...'); + await ctx.uploadArtifact({ + artifact: { + type: ManagedArtifactType.BUILD_ARTIFACTS, + paths: buildArtifacts, + }, + logger, + }); + } catch (err: any) { + logger.error({ err }, 'Failed to upload build artifacts'); + } +} + +export async function uploadApplicationArchive( + ctx: BuildContext, + { + logger, + patternOrPath, + rootDir, + }: { + logger: bunyan; + patternOrPath: string; + rootDir: string; + } +): Promise { + const applicationArchives = await findArtifacts({ rootDir, patternOrPath, logger }); + const artifactsSizes = await getArtifactsSizes(applicationArchives); + logger.info(`Application archives:`); + for (const artifactPath of applicationArchives) { + const maybeSize = artifactsSizes[artifactPath]; + logger.info(` - ${artifactPath}${maybeSize ? ` (${formatBytes(maybeSize)})` : ''}`); + } + logger.info('Uploading application archive...'); + await ctx.uploadArtifact({ + artifact: { + type: ManagedArtifactType.APPLICATION_ARCHIVE, + paths: applicationArchives, + }, + logger, + }); +} + +async function getArtifactsSizes(artifacts: string[]): Promise> { + const artifactsSizes: Record = {}; + await Promise.all( + artifacts.map(async (artifact) => { + artifactsSizes[artifact] = await getArtifactSize(artifact); + }) + ); + return artifactsSizes; +} + +async function getArtifactSize(artifact: string): Promise { + try { + const stat = await fs.stat(artifact); + if (!stat.isDirectory()) { + return stat.size; + } else { + const files = await fg('**/*', { cwd: artifact, onlyFiles: true }); + + if (files.length > 100_000) { + return undefined; + } + + const getFileSizePromiseLimit = promiseLimit(100); + const sizes = await Promise.all( + files.map((file) => + getFileSizePromiseLimit(async () => (await fs.stat(path.join(artifact, file))).size) + ) + ); + return sizes.reduce((acc, size) => acc + size, 0); + } + } catch { + return undefined; + } +} + +// same as in +// https://github.com/expo/eas-cli/blob/f0e3b648a1634266e7d723bd49a84866ab9b5801/packages/eas-cli/src/utils/files.ts#L33-L60 +export function formatBytes(bytes: number): string { + if (bytes === 0) { + return `0`; + } + let multiplier = 1; + if (bytes < 1024 * multiplier) { + return `${Math.floor(bytes)} B`; + } + multiplier *= 1024; + if (bytes < 102.4 * multiplier) { + return `${(bytes / multiplier).toFixed(1)} KB`; + } + if (bytes < 1024 * multiplier) { + return `${Math.floor(bytes / 1024)} KB`; + } + multiplier *= 1024; + if (bytes < 102.4 * multiplier) { + return `${(bytes / multiplier).toFixed(1)} MB`; + } + if (bytes < 1024 * multiplier) { + return `${Math.floor(bytes / multiplier)} MB`; + } + multiplier *= 1024; + if (bytes < 102.4 * multiplier) { + return `${(bytes / multiplier).toFixed(1)} GB`; + } + return `${Math.floor(bytes / 1024)} GB`; +} diff --git a/packages/build-tools/src/utils/cacheKey.ts b/packages/build-tools/src/utils/cacheKey.ts new file mode 100644 index 0000000000..22811c2eaa --- /dev/null +++ b/packages/build-tools/src/utils/cacheKey.ts @@ -0,0 +1,53 @@ +import path from 'path'; +import os from 'os'; +import assert from 'assert'; + +import * as PackageManagerUtils from '@expo/package-manager'; +import { hashFiles } from '@expo/steps'; +import { Platform } from '@expo/eas-build-job'; + +import { findPackagerRootDir } from './packageManager'; + +const IOS_CACHE_KEY_PREFIX = 'ios-ccache-'; +const ANDROID_CACHE_KEY_PREFIX = 'android-ccache-'; +const PUBLIC_IOS_CACHE_KEY_PREFIX = 'public-ios-ccache-'; +const PUBLIC_ANDROID_CACHE_KEY_PREFIX = 'public-android-ccache-'; +const DARWIN_CACHE_PATH = 'Library/Caches/ccache'; +const LINUX_CACHE_PATH = '.cache/ccache'; + +export const CACHE_KEY_PREFIX_BY_PLATFORM: Record = { + [Platform.ANDROID]: ANDROID_CACHE_KEY_PREFIX, + [Platform.IOS]: IOS_CACHE_KEY_PREFIX, +}; + +export const PUBLIC_CACHE_KEY_PREFIX_BY_PLATFORM: Record = { + [Platform.ANDROID]: PUBLIC_ANDROID_CACHE_KEY_PREFIX, + [Platform.IOS]: PUBLIC_IOS_CACHE_KEY_PREFIX, +}; + +const PATH_BY_PLATFORM: Record = { + darwin: DARWIN_CACHE_PATH, + linux: LINUX_CACHE_PATH, +}; + +export function getCcachePath(env: Record): string { + assert(env.HOME, 'Failed to infer directory: $HOME environment variable is empty.'); + return path.join(env.HOME, PATH_BY_PLATFORM[os.platform()]); +} + +export async function generateDefaultBuildCacheKeyAsync( + workingDirectory: string, + platform: Platform +): Promise { + // This will resolve which package manager and use the relevant lock file + // The lock file hash is the key and ensures cache is fresh + const packagerRunDir = findPackagerRootDir(workingDirectory); + const manager = PackageManagerUtils.createForProject(packagerRunDir); + const lockPath = path.join(packagerRunDir, manager.lockFile); + + try { + return `${CACHE_KEY_PREFIX_BY_PLATFORM[platform]}${hashFiles([lockPath])}`; + } catch (err: any) { + throw new Error(`Failed to read lockfile for cache key generation: ${err.message}`); + } +} diff --git a/packages/build-tools/src/utils/diffFingerprintsAsync.ts b/packages/build-tools/src/utils/diffFingerprintsAsync.ts new file mode 100644 index 0000000000..fc79a451b8 --- /dev/null +++ b/packages/build-tools/src/utils/diffFingerprintsAsync.ts @@ -0,0 +1,42 @@ +import { BuildStepEnv } from '@expo/steps'; +import { bunyan } from '@expo/logger'; + +import { + ExpoFingerprintCLICommandFailedError, + ExpoFingerprintCLIInvalidCommandError, + ExpoFingerprintCLIModuleNotFoundError, + expoFingerprintCommandAsync, + isModernExpoFingerprintCLISupportedAsync, +} from './expoFingerprintCli'; + +export async function diffFingerprintsAsync( + projectDir: string, + fingerprint1File: string, + fingerprint2File: string, + { env, logger }: { env: BuildStepEnv; logger: bunyan } +): Promise { + if (!(await isModernExpoFingerprintCLISupportedAsync(projectDir))) { + logger.debug('Fingerprint diff not available'); + return null; + } + + try { + return await expoFingerprintCommandAsync( + projectDir, + ['fingerprint:diff', fingerprint1File, fingerprint2File], + { + env, + } + ); + } catch (e) { + if ( + e instanceof ExpoFingerprintCLIModuleNotFoundError || + e instanceof ExpoFingerprintCLICommandFailedError || + e instanceof ExpoFingerprintCLIInvalidCommandError + ) { + logger.debug('Fingerprint diff not available'); + return null; + } + throw e; + } +} diff --git a/packages/build-tools/src/utils/environmentSecrets.ts b/packages/build-tools/src/utils/environmentSecrets.ts new file mode 100644 index 0000000000..8efb5ef341 --- /dev/null +++ b/packages/build-tools/src/utils/environmentSecrets.ts @@ -0,0 +1,25 @@ +import fs from 'fs'; +import path from 'path'; +import crypto from 'crypto'; + +export function createTemporaryEnvironmentSecretFile({ + secretsDir, + name, + contents_base64, +}: { + secretsDir: string; + name: string; + contents_base64: string; +}): string { + const contentsBuffer = Buffer.from(contents_base64, 'base64'); + + const hash = crypto.createHash('sha256'); + hash.update(`${name}:`); + hash.update(new Uint8Array(contentsBuffer)); + const key = hash.digest('hex'); + + const randomFilePath = path.join(secretsDir, key); + fs.writeFileSync(randomFilePath, new Uint8Array(contentsBuffer)); + + return randomFilePath; +} diff --git a/packages/build-tools/src/utils/expoFingerprintCli.ts b/packages/build-tools/src/utils/expoFingerprintCli.ts new file mode 100644 index 0000000000..ef386b8f6b --- /dev/null +++ b/packages/build-tools/src/utils/expoFingerprintCli.ts @@ -0,0 +1,84 @@ +import resolveFrom from 'resolve-from'; +import spawnAsync from '@expo/turtle-spawn'; +import { BuildStepEnv } from '@expo/steps'; +import fs from 'fs-extra'; +import semver from 'semver'; + +export class ExpoFingerprintCLIModuleNotFoundError extends Error {} +export class ExpoFingerprintCLIInvalidCommandError extends Error {} +export class ExpoFingerprintCLICommandFailedError extends Error {} + +function resolveExpoFingerprintCLI(projectRoot: string): string { + const expoPackageRoot = resolveFrom.silent(projectRoot, 'expo/package.json'); + try { + return ( + resolveFrom.silent(expoPackageRoot ?? projectRoot, '@expo/fingerprint/bin/cli') ?? + resolveFrom(expoPackageRoot ?? projectRoot, '@expo/fingerprint/bin/cli.js') + ); + } catch (e: any) { + if (e.code === 'MODULE_NOT_FOUND') { + throw new ExpoFingerprintCLIModuleNotFoundError( + `The \`@expo/fingerprint\` package was not found.` + ); + } + throw e; + } +} + +export async function expoFingerprintCommandAsync( + projectDir: string, + args: string[], + { env }: { env: BuildStepEnv } +): Promise { + const expoFingerprintCli = resolveExpoFingerprintCLI(projectDir); + try { + const spawnResult = await spawnAsync(expoFingerprintCli, args, { + stdio: 'pipe', + cwd: projectDir, + env, + }); + return spawnResult.stdout; + } catch (e: any) { + if (e.stderr && typeof e.stderr === 'string') { + if (e.stderr.includes('Invalid command')) { + throw new ExpoFingerprintCLIInvalidCommandError( + `The command specified by ${args} was not valid in the \`@expo/fingerprint\` CLI.` + ); + } else { + throw new ExpoFingerprintCLICommandFailedError(e.stderr); + } + } + throw e; + } +} + +async function getExpoFingerprintPackageVersionIfInstalledAsync( + projectDir: string +): Promise { + const expoPackageRoot = resolveFrom.silent(projectDir, 'expo/package.json'); + const maybePackageJson = resolveFrom.silent( + expoPackageRoot ?? projectDir, + '@expo/fingerprint/package.json' + ); + if (!maybePackageJson) { + return null; + } + const { version } = await fs.readJson(maybePackageJson); + return version ?? null; +} + +export async function isModernExpoFingerprintCLISupportedAsync( + projectDir: string +): Promise { + const expoFingerprintPackageVersion = + await getExpoFingerprintPackageVersionIfInstalledAsync(projectDir); + if (!expoFingerprintPackageVersion) { + return false; + } + + if (expoFingerprintPackageVersion.includes('canary')) { + return true; + } + + return semver.gte(expoFingerprintPackageVersion, '0.11.2'); +} diff --git a/packages/build-tools/src/utils/expoUpdates.ts b/packages/build-tools/src/utils/expoUpdates.ts new file mode 100644 index 0000000000..156f25309c --- /dev/null +++ b/packages/build-tools/src/utils/expoUpdates.ts @@ -0,0 +1,321 @@ +import assert from 'assert'; +import os from 'os'; +import path from 'path'; + +import { v4 as uuidv4 } from 'uuid'; +import { Platform, Job, BuildJob, Workflow } from '@expo/eas-build-job'; +import semver from 'semver'; +import { ExpoConfig } from '@expo/config'; +import { bunyan } from '@expo/logger'; +import { BuildStepEnv } from '@expo/steps'; +import fetch from 'node-fetch'; +import fs from 'fs-extra'; +import { graphql } from 'gql.tada'; + +import { + androidSetRuntimeVersionNativelyAsync, + androidSetChannelNativelyAsync, + androidGetNativelyDefinedRuntimeVersionAsync, + androidGetNativelyDefinedChannelAsync, +} from '../android/expoUpdates'; +import { + iosSetRuntimeVersionNativelyAsync, + iosSetChannelNativelyAsync, + iosGetNativelyDefinedRuntimeVersionAsync, + iosGetNativelyDefinedChannelAsync, +} from '../ios/expoUpdates'; +import { BuildContext } from '../context'; + +import getExpoUpdatesPackageVersionIfInstalledAsync from './getExpoUpdatesPackageVersionIfInstalledAsync'; +import { resolveRuntimeVersionAsync } from './resolveRuntimeVersionAsync'; +import { diffFingerprintsAsync } from './diffFingerprintsAsync'; +import { stringifyFingerprintDiff } from './fingerprint'; + +export async function setRuntimeVersionNativelyAsync( + ctx: BuildContext, + runtimeVersion: string +): Promise { + switch (ctx.job.platform) { + case Platform.ANDROID: { + await androidSetRuntimeVersionNativelyAsync(ctx, runtimeVersion); + return; + } + case Platform.IOS: { + await iosSetRuntimeVersionNativelyAsync(ctx, runtimeVersion); + return; + } + default: + throw new Error(`Platform is not supported.`); + } +} + +/** + * Used for when Expo Updates is pointed at an EAS server. + */ +export async function setChannelNativelyAsync(ctx: BuildContext): Promise { + assert(ctx.job.updates?.channel, 'updates.channel must be defined'); + const newUpdateRequestHeaders: Record = { + 'expo-channel-name': ctx.job.updates.channel, + }; + + const configFile = ctx.job.platform === Platform.ANDROID ? 'AndroidManifest.xml' : 'Expo.plist'; + ctx.logger.info( + `Setting the update request headers in '${configFile}' to '${JSON.stringify( + newUpdateRequestHeaders + )}'` + ); + + switch (ctx.job.platform) { + case Platform.ANDROID: { + await androidSetChannelNativelyAsync(ctx); + return; + } + case Platform.IOS: { + await iosSetChannelNativelyAsync(ctx); + return; + } + default: + throw new Error(`Platform is not supported.`); + } +} + +export async function configureEASExpoUpdatesAsync(ctx: BuildContext): Promise { + await setChannelNativelyAsync(ctx); +} + +type ResolvedRuntime = { + resolvedRuntimeVersion: string | null; + resolvedFingerprintSources?: object[] | null; +}; + +export async function configureExpoUpdatesIfInstalledAsync( + ctx: BuildContext, + resolvedRuntime: ResolvedRuntime +): Promise { + const expoUpdatesPackageVersion = await getExpoUpdatesPackageVersionIfInstalledAsync( + ctx.getReactNativeProjectDirectory(), + ctx.logger + ); + if (expoUpdatesPackageVersion === null) { + return; + } + + const appConfigRuntimeVersion = + ctx.job.version?.runtimeVersion ?? resolvedRuntime.resolvedRuntimeVersion; + + if (ctx.metadata?.runtimeVersion && ctx.metadata.runtimeVersion !== appConfigRuntimeVersion) { + ctx.logger.warn( + ` +Runtime version mismatch: +- Runtime version calculated on local machine: ${ctx.metadata.runtimeVersion} +- Runtime version calculated on EAS: ${appConfigRuntimeVersion} + +This may be due to one or more factors: +- Differing result of conditional app config (app.config.js) evaluation for runtime version resolution. +- Differing fingerprint when using fingerprint runtime version policy. If applicable, see fingerprint diff below. + +This would cause any updates published on the local machine to not be compatible with this build. +` + ); + await logDiffFingerprints({ resolvedRuntime, ctx }); + throw new Error( + 'Runtime version calculated on local machine not equal to runtime version calculated during build.' + ); + } + + if (isEASUpdateConfigured(ctx)) { + if (ctx.job.updates?.channel !== undefined) { + await configureEASExpoUpdatesAsync(ctx); + } else { + const channel = await getChannelAsync(ctx); + const isDevelopmentClient = ctx.job.developmentClient ?? false; + + if (channel !== null) { + const configFile = + ctx.job.platform === Platform.ANDROID ? 'AndroidManifest.xml' : 'Expo.plist'; + ctx.logger.info(`The channel name for EAS Update in ${configFile} is set to "${channel}"`); + } else if (isDevelopmentClient) { + // NO-OP: Development clients don't need to have a channel set + } else { + const easUpdateUrl = ctx.appConfig.updates?.url ?? null; + const jobProfile = ctx.job.buildProfile ?? null; + ctx.logger.warn( + `This build has an invalid EAS Update configuration: update.url is set to "${easUpdateUrl}" in app config, but a channel is not specified${ + jobProfile ? '' : ` for the current build profile "${jobProfile}" in eas.json` + }.` + ); + ctx.logger.warn(`- No channel will be set and EAS Update will be disabled for the build.`); + ctx.logger.warn( + `- Run \`eas update:configure\` to set your channel in eas.json. For more details, see https://docs.expo.dev/eas-update/getting-started/#configure-your-project` + ); + + ctx.markBuildPhaseHasWarnings(); + } + } + } + + if (ctx.job.version?.runtimeVersion) { + ctx.logger.info('Updating runtimeVersion in Expo.plist'); + await setRuntimeVersionNativelyAsync(ctx, ctx.job.version.runtimeVersion); + } +} + +export async function resolveRuntimeVersionForExpoUpdatesIfConfiguredAsync({ + cwd, + appConfig, + platform, + workflow, + logger, + env, +}: { + cwd: string; + appConfig: ExpoConfig; + platform: Platform; + workflow: Workflow; + logger: bunyan; + env: BuildStepEnv; +}): Promise<{ + runtimeVersion: string | null; + fingerprintSources: object[] | null; +} | null> { + const expoUpdatesPackageVersion = await getExpoUpdatesPackageVersionIfInstalledAsync(cwd, logger); + if (expoUpdatesPackageVersion === null) { + return null; + } + + const resolvedRuntimeVersion = await resolveRuntimeVersionAsync({ + projectDir: cwd, + exp: appConfig, + platform, + workflow, + logger, + expoUpdatesPackageVersion, + env, + }); + + logger.info(`Resolved runtime version: ${resolvedRuntimeVersion?.runtimeVersion}`); + return resolvedRuntimeVersion; +} + +export async function getChannelAsync(ctx: BuildContext): Promise { + switch (ctx.job.platform) { + case Platform.ANDROID: { + return await androidGetNativelyDefinedChannelAsync(ctx); + } + case Platform.IOS: { + return await iosGetNativelyDefinedChannelAsync(ctx); + } + default: + throw new Error(`Platform is not supported.`); + } +} + +export async function getRuntimeVersionAsync(ctx: BuildContext): Promise { + switch (ctx.job.platform) { + case Platform.ANDROID: { + return await androidGetNativelyDefinedRuntimeVersionAsync(ctx); + } + case Platform.IOS: { + return await iosGetNativelyDefinedRuntimeVersionAsync(ctx); + } + default: + throw new Error(`Platform is not supported.`); + } +} + +export function isEASUpdateConfigured(ctx: BuildContext): boolean { + const rawUrl = ctx.appConfig.updates?.url; + if (!rawUrl) { + return false; + } + try { + const url = new URL(rawUrl); + return ['u.expo.dev', 'staging-u.expo.dev'].includes(url.hostname); + } catch (err) { + ctx.logger.error({ err }, `Cannot parse expo.updates.url = ${rawUrl} as URL`); + ctx.logger.error(`Assuming EAS Update is not configured`); + return false; + } +} + +export function isModernExpoUpdatesCLIWithRuntimeVersionCommandSupported( + expoUpdatesPackageVersion: string +): boolean { + if (expoUpdatesPackageVersion.includes('canary')) { + return true; + } + + // Anything SDK 51 or greater uses the expo-updates CLI + return semver.gte(expoUpdatesPackageVersion, '0.25.4'); +} + +async function logDiffFingerprints({ + resolvedRuntime, + ctx, +}: { + resolvedRuntime: ResolvedRuntime; + ctx: BuildContext; +}): Promise { + const { resolvedRuntimeVersion, resolvedFingerprintSources } = resolvedRuntime; + + const fingerprintInfo = await ctx.graphqlClient + .query( + graphql(` + query GetFingerprintUrl($id: ID!) { + builds { + byId(buildId: $id) { + fingerprint { + debugInfoUrl + } + } + } + } + `), + { id: ctx.env.EAS_BUILD_ID } + ) + .toPromise(); + + if (fingerprintInfo.error) { + ctx.logger.warn('Failed to fetch current fingerprint info', fingerprintInfo.error); + return; + } + + if ( + fingerprintInfo.data?.builds.byId?.fingerprint?.debugInfoUrl && + resolvedFingerprintSources && + resolvedRuntimeVersion + ) { + try { + const result = await fetch(fingerprintInfo.data.builds.byId.fingerprint.debugInfoUrl); + const localFingerprintJSON = await result.json(); + const localFingerprintFile = path.join( + os.tmpdir(), + `eas-build-${uuidv4()}-local-fingerprint` + ); + await fs.writeFile(localFingerprintFile, JSON.stringify(localFingerprintJSON)); + + const easFingerprint = { + hash: resolvedRuntimeVersion, + sources: resolvedFingerprintSources, + }; + const easFingerprintFile = path.join(os.tmpdir(), `eas-build-${uuidv4()}-eas-fingerprint`); + await fs.writeFile(easFingerprintFile, JSON.stringify(easFingerprint)); + + const changesJSONString = await diffFingerprintsAsync( + ctx.getReactNativeProjectDirectory(), + localFingerprintFile, + easFingerprintFile, + { env: ctx.env, logger: ctx.logger } + ); + if (changesJSONString) { + const changes = JSON.parse(changesJSONString); + if (changes.length) { + ctx.logger.warn('Difference between local and EAS fingerprints:'); + ctx.logger.warn(stringifyFingerprintDiff(changes)); + } + } + } catch (error) { + ctx.logger.warn('Failed to compare fingerprints', error); + } + } +} diff --git a/packages/build-tools/src/utils/expoUpdatesCli.ts b/packages/build-tools/src/utils/expoUpdatesCli.ts new file mode 100644 index 0000000000..a430f6ce1f --- /dev/null +++ b/packages/build-tools/src/utils/expoUpdatesCli.ts @@ -0,0 +1,47 @@ +import resolveFrom, { silent as silentResolveFrom } from 'resolve-from'; +import spawnAsync from '@expo/turtle-spawn'; +import { BuildStepEnv } from '@expo/steps'; + +export class ExpoUpdatesCLIModuleNotFoundError extends Error {} +export class ExpoUpdatesCLIInvalidCommandError extends Error {} +export class ExpoUpdatesCLICommandFailedError extends Error {} + +export async function expoUpdatesCommandAsync( + projectDir: string, + args: string[], + { env }: { env: BuildStepEnv } +): Promise { + let expoUpdatesCli; + try { + expoUpdatesCli = + silentResolveFrom(projectDir, 'expo-updates/bin/cli') ?? + resolveFrom(projectDir, 'expo-updates/bin/cli.js'); + } catch (e: any) { + if (e.code === 'MODULE_NOT_FOUND') { + throw new ExpoUpdatesCLIModuleNotFoundError( + `The \`expo-updates\` package was not found. Follow the installation directions at https://docs.expo.dev/bare/installing-expo-modules/` + ); + } + throw e; + } + + try { + const spawnResult = await spawnAsync(expoUpdatesCli, args, { + stdio: 'pipe', + cwd: projectDir, + env, + }); + return spawnResult.stdout; + } catch (e: any) { + if (e.stderr && typeof e.stderr === 'string') { + if (e.stderr.includes('Invalid command')) { + throw new ExpoUpdatesCLIInvalidCommandError( + `The command specified by ${args} was not valid in the \`expo-updates\` CLI.` + ); + } else { + throw new ExpoUpdatesCLICommandFailedError(e.stderr); + } + } + throw e; + } +} diff --git a/packages/build-tools/src/utils/files.ts b/packages/build-tools/src/utils/files.ts new file mode 100644 index 0000000000..0fa6e15490 --- /dev/null +++ b/packages/build-tools/src/utils/files.ts @@ -0,0 +1,37 @@ +import fsPromises from 'node:fs/promises'; + +import * as tar from 'tar'; + +export async function decompressTarAsync({ + archivePath, + destinationDirectory, +}: { + archivePath: string; + destinationDirectory: string; +}): Promise { + await tar.extract({ + file: archivePath, + cwd: destinationDirectory, + }); +} + +export async function isFileTarGzAsync(path: string): Promise { + if (path.endsWith('tar.gz') || path.endsWith('.tgz')) { + return true; + } + + // read only first 3 bytes to check if it's gzip + const fd = await fsPromises.open(path, 'r'); + const buffer = new Uint8Array(3); + await fd.read(buffer, 0, 3, 0); + await fd.close(); + + if (buffer.length < 3) { + return false; + } + + // Check whether provided `buffer` is a valid Gzip file header + // Gzip files always begin with 0x1F 0x8B 0x08 magic bytes + // Source: https://en.wikipedia.org/wiki/Gzip#File_format + return buffer[0] === 0x1f && buffer[1] === 0x8b && buffer[2] === 0x08; +} diff --git a/packages/build-tools/src/utils/findMaestroPathsFlowsToExecuteAsync.ts b/packages/build-tools/src/utils/findMaestroPathsFlowsToExecuteAsync.ts new file mode 100644 index 0000000000..f681608d1c --- /dev/null +++ b/packages/build-tools/src/utils/findMaestroPathsFlowsToExecuteAsync.ts @@ -0,0 +1,231 @@ +import { promises as fs } from 'node:fs'; +import * as path from 'node:path'; + +import { bunyan } from '@expo/logger'; +import * as yaml from 'yaml'; +import { z } from 'zod'; +import { asyncResult } from '@expo/results'; +import fg from 'fast-glob'; + +const FlowConfigSchema = z.object({ + name: z.string().optional(), + tags: z.array(z.string()).optional(), +}); + +const WorkspaceConfigSchema = z.object({ + flows: z.array(z.string()).optional(), + executionOrder: z.record(z.string(), z.unknown()).optional(), + includeTags: z.array(z.string()).optional(), + excludeTags: z.array(z.string()).optional(), +}); + +type FlowConfig = z.infer; +type WorkspaceConfig = z.infer; + +export async function findMaestroPathsFlowsToExecuteAsync({ + workingDirectory, + flowPath, + includeTags: _includeTags, + excludeTags: _excludeTags, + logger, +}: { + workingDirectory: string; + flowPath: string; + includeTags: string[] | undefined; + excludeTags: string[] | undefined; + logger: bunyan; +}): Promise { + const absoluteFlowPath = path.resolve(workingDirectory, flowPath); + // If it's a file, just return it (no validation needed) + const stat = await fs.stat(absoluteFlowPath); + + if (stat.isFile()) { + logger.info(`Found a file: ${path.relative(workingDirectory, absoluteFlowPath)}`); + return [absoluteFlowPath]; + } + + // It's a directory - discover flow files + logger.info(`Found a directory: ${path.relative(workingDirectory, absoluteFlowPath)}`); + + // Check for workspace config + logger.info(`Searching for workspace config...`); + const workspaceConfig = await findAndParseWorkspaceConfigAsync({ + dirPath: absoluteFlowPath, + workingDirectory, + logger, + }); + logger.info(`Using workspace config: ${JSON.stringify(workspaceConfig)}`); + + if (workspaceConfig?.executionOrder) { + logger.warn(`Execution order is not supported yet. Ignoring.`); + } + + logger.info(`Searching for flow files...`); + const { flows } = await findAndParseFlowFilesAsync({ + dirPath: absoluteFlowPath, + workingDirectory, + workspaceConfig, + logger, + }); + + if (flows.length === 0) { + logger.info( + `No valid flow files found in: ${path.relative(workingDirectory, absoluteFlowPath)}` + ); + return []; + } + + const includeTags = [...(_includeTags ?? []), ...(workspaceConfig?.includeTags ?? [])]; + const excludeTags = [...(_excludeTags ?? []), ...(workspaceConfig?.excludeTags ?? [])]; + + if (includeTags.length === 0 && excludeTags.length === 0) { + logger.info(`No tags provided, returning all flows.`); + return flows.map(({ path }) => path); + } + + logger.info( + `Filtering flows by tags. Tags to include: ${JSON.stringify(includeTags)}. Tags to exclude: ${JSON.stringify(excludeTags) ?? 'none'}.` + ); + return flows + .filter(({ config, path: flowPath }) => { + const shouldInclude = matchesTags({ + flowTags: config?.tags ?? [], + includeTags, + excludeTags, + }); + + logger.info( + shouldInclude + ? `- ${path.relative(workingDirectory, flowPath)} matches tags, including.` + : `- ${path.relative(workingDirectory, flowPath)} does not match tags, excluding.` + ); + + return shouldInclude; + }) + .map(({ path }) => path); +} + +async function findAndParseWorkspaceConfigAsync({ + dirPath, + workingDirectory, + logger, +}: { + dirPath: string; + workingDirectory: string; + logger: bunyan; +}): Promise { + const configPaths = await fg(['config.yaml', 'config.yml'], { + cwd: dirPath, + absolute: true, + }); + + if (configPaths.length === 0) { + logger.info(`No workspace config found in: ${path.relative(workingDirectory, dirPath)}`); + return null; + } + + for (const configPath of configPaths) { + try { + const content = await fs.readFile(configPath, 'utf-8'); + const configDoc = yaml.parse(content); + if (!configDoc) { + logger.warn( + `No content found in workspace config: ${path.relative(workingDirectory, configPath)}` + ); + continue; + } + logger.info(`Using workspace config from: ${path.relative(workingDirectory, configPath)}`); + return WorkspaceConfigSchema.parse(configDoc); + } catch (err) { + logger.warn( + { err }, + `Failed to parse workspace config: ${path.relative(workingDirectory, configPath)}` + ); + continue; + } + } + + logger.info(`No valid workspace config found in: ${path.relative(workingDirectory, dirPath)}`); + return null; +} + +async function findAndParseFlowFilesAsync({ + workingDirectory, + dirPath, + workspaceConfig, + logger, +}: { + workingDirectory: string; + dirPath: string; + workspaceConfig: WorkspaceConfig | null; + logger: bunyan; +}): Promise<{ flows: { config: FlowConfig; path: string }[] }> { + const flows: { config: FlowConfig; path: string }[] = []; + + // Determine flow patterns from config or use default + const flowPatterns = workspaceConfig?.flows ?? ['*']; + logger.info(`Using flow patterns: ${JSON.stringify(flowPatterns)}`); + + // Use fast-glob to find matching files + const matchedFiles = await fg(flowPatterns, { + cwd: dirPath, + absolute: true, + onlyFiles: true, + ignore: ['*/config.yaml', '*/config.yml'], // Skip workspace config files + }); + + logger.info(`Found ${matchedFiles.length} potential flow files`); + + // Parse each matched file + for (const filePath of matchedFiles) { + // Skip non-YAML files + const ext = path.extname(filePath); + if (ext !== '.yaml' && ext !== '.yml') { + logger.info(`Skipping non-YAML file: ${path.relative(workingDirectory, filePath)}`); + continue; + } + + const result = await asyncResult(parseFlowFile(filePath)); + if (result.ok) { + logger.info(`Found flow file: ${path.relative(workingDirectory, filePath)}`); + flows.push({ config: result.value, path: filePath }); + } else { + logger.info( + { err: result.reason }, + `Skipping flow file: ${path.relative(workingDirectory, filePath)}` + ); + } + } + + return { flows }; +} + +async function parseFlowFile(filePath: string): Promise { + const content = await fs.readFile(filePath, 'utf-8'); + const documents = yaml.parseAllDocuments(content); + const configDoc = documents[0]; + if (!configDoc) { + throw new Error(`No config section found in ${filePath}`); + } + return FlowConfigSchema.parse(configDoc.toJS()); +} + +function matchesTags({ + flowTags, + includeTags, + excludeTags, +}: { + flowTags: string[]; + includeTags: string[]; + excludeTags: string[]; +}): boolean { + // Include logic: if includeTags is empty OR flow has any of the include tags + const includeMatch = + includeTags.length === 0 || includeTags.some((tag) => flowTags.includes(tag)); + + // Exclude logic: if excludeTags is empty OR flow has none of the exclude tags + const excludeMatch = + excludeTags.length === 0 || !excludeTags.some((tag) => flowTags.includes(tag)); + + return includeMatch && excludeMatch; +} diff --git a/packages/build-tools/src/utils/fingerprint.ts b/packages/build-tools/src/utils/fingerprint.ts new file mode 100644 index 0000000000..354e11f078 --- /dev/null +++ b/packages/build-tools/src/utils/fingerprint.ts @@ -0,0 +1,17 @@ +export function stringifyFingerprintDiff(fingerprintDiff: object[]): string { + return JSON.stringify( + fingerprintDiff, + (key, value) => { + if (key === 'contents') { + try { + const item = JSON.parse(value); + return item; + } catch { + return value; + } + } + return value; + }, + ' ' + ); +} diff --git a/packages/build-tools/src/utils/getExpoUpdatesPackageVersionIfInstalledAsync.ts b/packages/build-tools/src/utils/getExpoUpdatesPackageVersionIfInstalledAsync.ts new file mode 100644 index 0000000000..8bc06daa8a --- /dev/null +++ b/packages/build-tools/src/utils/getExpoUpdatesPackageVersionIfInstalledAsync.ts @@ -0,0 +1,22 @@ +import { bunyan } from '@expo/logger'; +import fs from 'fs-extra'; +import resolveFrom from 'resolve-from'; + +export default async function getExpoUpdatesPackageVersionIfInstalledAsync( + reactNativeProjectDirectory: string, + logger: bunyan +): Promise { + const maybePackageJson = resolveFrom.silent( + reactNativeProjectDirectory, + 'expo-updates/package.json' + ); + + let versionOuter: string | null = null; + if (maybePackageJson) { + const { version } = await fs.readJson(maybePackageJson); + versionOuter = version; + } + + logger.debug(`Resolved expo-updates package version: ${versionOuter}`); + return versionOuter; +} diff --git a/packages/build-tools/src/utils/hooks.ts b/packages/build-tools/src/utils/hooks.ts new file mode 100644 index 0000000000..9f699747e0 --- /dev/null +++ b/packages/build-tools/src/utils/hooks.ts @@ -0,0 +1,47 @@ +import { BuildJob } from '@expo/eas-build-job'; +import spawn from '@expo/turtle-spawn'; + +import { BuildContext } from '../context'; + +import { PackageManager } from './packageManager'; +import { readPackageJson } from './project'; + +export enum Hook { + PRE_INSTALL = 'eas-build-pre-install', + POST_INSTALL = 'eas-build-post-install', + /** + * @deprecated + */ + PRE_UPLOAD_ARTIFACTS = 'eas-build-pre-upload-artifacts', + ON_BUILD_SUCCESS = 'eas-build-on-success', + ON_BUILD_ERROR = 'eas-build-on-error', + ON_BUILD_COMPLETE = 'eas-build-on-complete', + ON_BUILD_CANCEL = 'eas-build-on-cancel', +} + +export async function runHookIfPresent( + ctx: BuildContext, + hook: Hook, + { extraEnvs }: { extraEnvs?: Record } = {} +): Promise { + const projectDir = ctx.getReactNativeProjectDirectory(); + const packageJson = readPackageJson(projectDir); + if (packageJson.scripts?.[hook]) { + ctx.logger.info(`Script '${hook}' is present in package.json, running it...`); + // both yarn v2+ and yarn v1 seem to have issues with running preinstall script in some cases + // like doing corepack enable + // https://exponent-internal.slack.com/archives/C9PRD479V/p1736426668589209 + const packageManager = + ctx.packageManager === PackageManager.YARN && hook === Hook.PRE_INSTALL + ? PackageManager.NPM + : ctx.packageManager; + await spawn(packageManager, ['run', hook], { + cwd: projectDir, + logger: ctx.logger, + env: { + ...ctx.env, + ...extraEnvs, + }, + }); + } +} diff --git a/packages/build-tools/src/utils/npmrc.ts b/packages/build-tools/src/utils/npmrc.ts new file mode 100644 index 0000000000..39430a7451 --- /dev/null +++ b/packages/build-tools/src/utils/npmrc.ts @@ -0,0 +1,40 @@ +import path from 'path'; + +import { Job } from '@expo/eas-build-job'; +import { bunyan } from '@expo/logger'; +import fs from 'fs-extra'; + +import { BuildContext } from '../context'; +import { NpmrcTemplate } from '../templates/npmrc'; + +import { findPackagerRootDir } from './packageManager'; + +export async function setUpNpmrcAsync(ctx: BuildContext, logger: bunyan): Promise { + if (ctx.env.NPM_TOKEN) { + await createNpmrcIfNotExistsAsync(ctx, logger); + } else { + await logIfNpmrcExistsAsync(ctx, logger); + } +} + +async function createNpmrcIfNotExistsAsync(ctx: BuildContext, logger: bunyan): Promise { + logger.info('We detected that you set the NPM_TOKEN environment variable'); + const projectNpmrcPath = path.join(ctx.buildDirectory, '.npmrc'); + if (await fs.pathExists(projectNpmrcPath)) { + logger.info('.npmrc already exists in your project directory, skipping generation'); + } else { + logger.info('Creating .npmrc in your project directory with the following contents:'); + logger.info(NpmrcTemplate); + await fs.writeFile(projectNpmrcPath, NpmrcTemplate); + } +} + +async function logIfNpmrcExistsAsync(ctx: BuildContext, logger: bunyan): Promise { + const projectNpmrcPath = path.join( + findPackagerRootDir(ctx.getReactNativeProjectDirectory()), + '.npmrc' + ); + if (await fs.pathExists(projectNpmrcPath)) { + logger.info(`.npmrc found at ${path.relative(ctx.buildDirectory, projectNpmrcPath)}`); + } +} diff --git a/packages/build-tools/src/utils/outputs.ts b/packages/build-tools/src/utils/outputs.ts new file mode 100644 index 0000000000..8ac0ac8191 --- /dev/null +++ b/packages/build-tools/src/utils/outputs.ts @@ -0,0 +1,62 @@ +import { JobInterpolationContext } from '@expo/eas-build-job'; +import { BuildStepGlobalContext, jsepEval } from '@expo/steps'; +import { bunyan } from '@expo/logger'; +import nullthrows from 'nullthrows'; + +import { turtleFetch } from './turtleFetch'; + +export async function uploadJobOutputsToWwwAsync( + ctx: BuildStepGlobalContext, + { logger, expoApiV2BaseUrl }: { logger: bunyan; expoApiV2BaseUrl: string } +): Promise { + if (!ctx.staticContext.job.outputs) { + logger.info('Job defines no outputs, skipping upload'); + return; + } + + try { + const workflowJobId = nullthrows(ctx.env.__WORKFLOW_JOB_ID); + const robotAccessToken = nullthrows(ctx.staticContext.job.secrets?.robotAccessToken); + + const interpolationContext = ctx.getInterpolationContext(); + logger.debug({ dynamicValues: interpolationContext }, 'Using dynamic values'); + + const outputs = collectJobOutputs({ + jobOutputDefinitions: ctx.staticContext.job.outputs, + interpolationContext, + }); + logger.info('Uploading outputs'); + + await turtleFetch(new URL(`workflows/${workflowJobId}`, expoApiV2BaseUrl).toString(), 'PATCH', { + json: { outputs }, + headers: { + Authorization: `Bearer ${robotAccessToken}`, + }, + timeout: 20000, + logger, + }); + } catch (err) { + logger.error({ err }, 'Failed to upload outputs'); + throw err; + } +} + +/** Function we use to get outputs of the whole job from steps. */ +export function collectJobOutputs({ + jobOutputDefinitions, + interpolationContext, +}: { + jobOutputDefinitions: Record; + interpolationContext: JobInterpolationContext; +}): Record { + const jobOutputs: Record = {}; + for (const [outputKey, outputDefinition] of Object.entries(jobOutputDefinitions)) { + const outputValue = outputDefinition.replace(/\$\{\{(.+?)\}\}/g, (_match, expression) => { + return `${jsepEval(expression, interpolationContext) ?? ''}`; + }); + + jobOutputs[outputKey] = outputValue; + } + + return jobOutputs; +} diff --git a/packages/build-tools/src/utils/packageManager.ts b/packages/build-tools/src/utils/packageManager.ts new file mode 100644 index 0000000000..d803b386c6 --- /dev/null +++ b/packages/build-tools/src/utils/packageManager.ts @@ -0,0 +1,93 @@ +import spawnAsync from '@expo/turtle-spawn'; +import * as PackageManagerUtils from '@expo/package-manager'; +import semver from 'semver'; +import { z } from 'zod'; + +export enum PackageManager { + YARN = 'yarn', + NPM = 'npm', + PNPM = 'pnpm', + BUN = 'bun', +} + +export function resolvePackageManager(directory: string): PackageManager { + try { + const manager = PackageManagerUtils.resolvePackageManager(directory); + if (manager === 'npm') { + return PackageManager.NPM; + } else if (manager === 'pnpm') { + return PackageManager.PNPM; + } else if (manager === 'bun') { + return PackageManager.BUN; + } else { + return PackageManager.YARN; + } + } catch { + return PackageManager.YARN; + } +} + +export function findPackagerRootDir(currentDir: string): string { + return PackageManagerUtils.resolveWorkspaceRoot(currentDir) ?? currentDir; +} + +export async function isAtLeastNpm7Async(): Promise { + const version = (await spawnAsync('npm', ['--version'], { stdio: 'pipe' })).stdout.trim(); + return semver.gte(version, '7.0.0'); +} + +export function shouldUseFrozenLockfile({ + env, + sdkVersion, + reactNativeVersion, +}: { + env: Record; + sdkVersion: string | undefined; + reactNativeVersion: string | undefined; +}): boolean { + if (env.EAS_NO_FROZEN_LOCKFILE) { + return false; + } + + if (sdkVersion && semver.lt(sdkVersion, '53.0.0')) { + // Before SDK 53 we could not have used frozen lockfile. + return false; + } + + if (reactNativeVersion && semver.lt(reactNativeVersion, '0.79.0')) { + // Before react-native 0.79 we could not have used frozen lockfile. + return false; + } + + // We either don't know expo and react-native versions, + // so we can try to use frozen lockfile, or the versions are + // new enough that we do want to use it. + return true; +} + +const PackageJsonZ = z.object({ + dependencies: z.record(z.string(), z.string()).optional(), + devDependencies: z.record(z.string(), z.string()).optional(), +}); + +export function getPackageVersionFromPackageJson({ + packageJson, + packageName, +}: { + packageJson: unknown; + packageName: string; +}): string | undefined { + const parsedPackageJson = PackageJsonZ.safeParse(packageJson); + if (!parsedPackageJson.success) { + return undefined; + } + + const version = + parsedPackageJson.data.dependencies?.[packageName] ?? + parsedPackageJson.data.devDependencies?.[packageName]; + if (!version) { + return undefined; + } + + return semver.coerce(version)?.version; +} diff --git a/packages/build-tools/src/utils/prepareBuildExecutable.ts b/packages/build-tools/src/utils/prepareBuildExecutable.ts new file mode 100644 index 0000000000..8013cbbfd1 --- /dev/null +++ b/packages/build-tools/src/utils/prepareBuildExecutable.ts @@ -0,0 +1,13 @@ +import path from 'path'; + +import { Job } from '@expo/eas-build-job'; +import fs from 'fs-extra'; + +import { BuildContext } from '../context'; + +export async function prepareExecutableAsync(ctx: BuildContext): Promise { + await fs.copy( + path.join(__dirname, '../../bin/set-env'), + path.join(ctx.buildExecutablesDirectory, 'set-env') + ); +} diff --git a/packages/build-tools/src/utils/processes.ts b/packages/build-tools/src/utils/processes.ts new file mode 100644 index 0000000000..49b01e618c --- /dev/null +++ b/packages/build-tools/src/utils/processes.ts @@ -0,0 +1,32 @@ +import spawn from '@expo/turtle-spawn'; + +async function getChildrenPidsAsync(parentPids: number[]): Promise { + try { + const result = await spawn('pgrep', ['-P', parentPids.join(',')], { + stdio: 'pipe', + }); + return result.stdout + .toString() + .split('\n') + .map((i) => Number(i.trim())) + .filter((i) => i); + } catch { + return []; + } +} + +export async function getParentAndDescendantProcessPidsAsync(ppid: number): Promise { + const children = new Set([ppid]); + let shouldCheckAgain = true; + while (shouldCheckAgain) { + const pids = await getChildrenPidsAsync([...children]); + shouldCheckAgain = false; + for (const pid of pids) { + if (!children.has(pid)) { + shouldCheckAgain = true; + children.add(pid); + } + } + } + return [...children]; +} diff --git a/packages/build-tools/src/utils/project.ts b/packages/build-tools/src/utils/project.ts new file mode 100644 index 0000000000..624fd5630a --- /dev/null +++ b/packages/build-tools/src/utils/project.ts @@ -0,0 +1,50 @@ +import path from 'path'; + +import spawn, { SpawnOptions, SpawnPromise, SpawnResult } from '@expo/turtle-spawn'; +import fs from 'fs-extra'; + +import { findPackagerRootDir, PackageManager } from '../utils/packageManager'; + +/** + * check if .yarnrc.yml exists in the project dir or in the workspace root dir + */ +export async function isUsingModernYarnVersion(projectDir: string): Promise { + const yarnrcPath = path.join(projectDir, '.yarnrc.yml'); + const yarnrcRootPath = path.join(findPackagerRootDir(projectDir), '.yarnrc.yml'); + return (await fs.pathExists(yarnrcPath)) || (await fs.pathExists(yarnrcRootPath)); +} + +export function runExpoCliCommand({ + packageManager, + args, + options, +}: { + packageManager: PackageManager; + args: string[]; + options: SpawnOptions; +}): SpawnPromise { + const argsWithExpo = ['expo', ...args]; + if (packageManager === PackageManager.NPM) { + return spawn('npx', argsWithExpo, options); + } else if (packageManager === PackageManager.YARN) { + return spawn('yarn', argsWithExpo, options); + } else if (packageManager === PackageManager.PNPM) { + return spawn('pnpm', argsWithExpo, options); + } else if (packageManager === PackageManager.BUN) { + return spawn('bun', argsWithExpo, options); + } else { + throw new Error(`Unsupported package manager: ${packageManager}`); + } +} + +export function readPackageJson(projectDir: string): any { + const packageJsonPath = path.join(projectDir, 'package.json'); + if (!fs.pathExistsSync(packageJsonPath)) { + throw new Error(`package.json does not exist in ${projectDir}`); + } + try { + return fs.readJSONSync(packageJsonPath); + } catch (err: any) { + throw new Error(`Failed to parse or read package.json: ${err.message}`); + } +} diff --git a/packages/build-tools/src/utils/promiseRetryWithCondition.ts b/packages/build-tools/src/utils/promiseRetryWithCondition.ts new file mode 100644 index 0000000000..a62983d2b0 --- /dev/null +++ b/packages/build-tools/src/utils/promiseRetryWithCondition.ts @@ -0,0 +1,20 @@ +import promiseRetry from 'promise-retry'; +import { OperationOptions } from 'retry'; + +export function promiseRetryWithCondition Promise>( + fn: TFn, + retryConditionFn: (error: any) => boolean, + options: OperationOptions = { retries: 3, factor: 2 } +): (...funcArgs: Parameters) => Promise> { + return (...funcArgs) => + promiseRetry>(async (retry) => { + try { + return await fn(...funcArgs); + } catch (e) { + if (retryConditionFn(e)) { + retry(e); + } + throw e; + } + }, options); +} diff --git a/packages/build-tools/src/utils/resolveRuntimeVersionAsync.ts b/packages/build-tools/src/utils/resolveRuntimeVersionAsync.ts new file mode 100644 index 0000000000..393c894d23 --- /dev/null +++ b/packages/build-tools/src/utils/resolveRuntimeVersionAsync.ts @@ -0,0 +1,71 @@ +import { ExpoConfig } from '@expo/config'; +import { Updates } from '@expo/config-plugins'; +import { bunyan } from '@expo/logger'; +import { Workflow } from '@expo/eas-build-job'; +import { BuildStepEnv } from '@expo/steps'; + +import { ExpoUpdatesCLIModuleNotFoundError, expoUpdatesCommandAsync } from './expoUpdatesCli'; +import { isModernExpoUpdatesCLIWithRuntimeVersionCommandSupported } from './expoUpdates'; + +export async function resolveRuntimeVersionAsync({ + exp, + platform, + workflow, + projectDir, + logger, + expoUpdatesPackageVersion, + env, +}: { + exp: ExpoConfig; + platform: 'ios' | 'android'; + workflow: Workflow; + projectDir: string; + logger: bunyan; + expoUpdatesPackageVersion: string; + env: BuildStepEnv; +}): Promise<{ + runtimeVersion: string | null; + fingerprintSources: object[] | null; +} | null> { + if (!isModernExpoUpdatesCLIWithRuntimeVersionCommandSupported(expoUpdatesPackageVersion)) { + logger.debug('Using expo-updates config plugin for runtime version resolution'); + // fall back to the previous behavior (using the @expo/config-plugins eas-cli dependency rather + // than the versioned @expo/config-plugins dependency in the project) + return { + runtimeVersion: await Updates.getRuntimeVersionNullableAsync(projectDir, exp, platform), + fingerprintSources: null, + }; + } + + try { + logger.debug('Using expo-updates runtimeversion:resolve CLI for runtime version resolution'); + + const extraArgs = logger.debug() ? ['--debug'] : []; + + const resolvedRuntimeVersionJSONResult = await expoUpdatesCommandAsync( + projectDir, + ['runtimeversion:resolve', '--platform', platform, '--workflow', workflow, ...extraArgs], + { + env, + } + ); + const runtimeVersionResult = JSON.parse(resolvedRuntimeVersionJSONResult); + + logger.debug('runtimeversion:resolve output:'); + logger.debug(resolvedRuntimeVersionJSONResult); + + return { + runtimeVersion: runtimeVersionResult.runtimeVersion ?? null, + fingerprintSources: runtimeVersionResult.fingerprintSources ?? null, + }; + } catch (e: any) { + // if expo-updates is not installed, there's no need for a runtime version in the build + if (e instanceof ExpoUpdatesCLIModuleNotFoundError) { + logger.error( + `Error when resolving runtime version using expo-updates runtimeversion:resolve CLI: ${e.message}` + ); + return null; + } + throw e; + } +} diff --git a/packages/build-tools/src/utils/retry.ts b/packages/build-tools/src/utils/retry.ts new file mode 100644 index 0000000000..c43b736c40 --- /dev/null +++ b/packages/build-tools/src/utils/retry.ts @@ -0,0 +1,38 @@ +import { bunyan } from '@expo/logger'; + +export async function sleepAsync(ms: number): Promise { + await new Promise((res) => setTimeout(res, ms)); +} + +export interface RetryOptions { + retries: number; + retryIntervalMs: number; +} + +export async function retryAsync( + fn: (attemptCount: number) => Promise, + { + retryOptions: { retries, retryIntervalMs }, + logger, + }: { + retryOptions: RetryOptions; + logger?: bunyan; + } +): Promise { + let attemptCount = -1; + for (;;) { + try { + attemptCount += 1; + return await fn(attemptCount); + } catch (err: any) { + logger?.debug( + { err, stdout: err.stdout, stderr: err.stderr }, + `Retry attempt ${attemptCount}` + ); + await sleepAsync(retryIntervalMs); + if (attemptCount === retries) { + throw err; + } + } + } +} diff --git a/packages/build-tools/src/utils/retryOnDNSFailure.ts b/packages/build-tools/src/utils/retryOnDNSFailure.ts new file mode 100644 index 0000000000..586b0fbc1b --- /dev/null +++ b/packages/build-tools/src/utils/retryOnDNSFailure.ts @@ -0,0 +1,19 @@ +import { OperationOptions } from 'retry'; + +import { promiseRetryWithCondition } from './promiseRetryWithCondition'; + +export function isDNSError(e: Error & { code: any }): boolean { + return e.code === 'ENOTFOUND' || e.code === 'EAI_AGAIN'; +} + +export function retryOnDNSFailure Promise>( + fn: TFn, + options?: OperationOptions +): (...funcArgs: Parameters) => Promise> { + return promiseRetryWithCondition(fn, isDNSError, { + retries: 3, + factor: 2, + minTimeout: 100, + ...options, + }); +} diff --git a/packages/build-tools/src/utils/strings.ts b/packages/build-tools/src/utils/strings.ts new file mode 100644 index 0000000000..c6cdc39826 --- /dev/null +++ b/packages/build-tools/src/utils/strings.ts @@ -0,0 +1,17 @@ +import { Platform } from '@expo/eas-build-job'; + +const PLURAL_WORDS: Record = { + entry: 'entries', +}; + +export const pluralize = (count: number, word: string): string => { + const shouldUsePluralWord = count > 1 || count === 0; + const pluralWord = PLURAL_WORDS[word] ?? `${word}s`; + + return shouldUsePluralWord ? pluralWord : word; +}; + +export const PlatformToProperNounMap: Record = { + [Platform.ANDROID]: 'Android', + [Platform.IOS]: 'iOS', +}; diff --git a/packages/build-tools/src/utils/turtleFetch.ts b/packages/build-tools/src/utils/turtleFetch.ts new file mode 100644 index 0000000000..fa01417662 --- /dev/null +++ b/packages/build-tools/src/utils/turtleFetch.ts @@ -0,0 +1,74 @@ +import fetch, { Response, RequestInit, HeaderInit } from 'node-fetch'; +import { bunyan } from '@expo/logger'; + +import { retryAsync } from './retry'; + +type TurtleFetchMethod = 'GET' | 'POST' | 'PUT' | 'DELETE' | 'HEAD' | 'OPTIONS' | 'TRACE' | 'PATCH'; + +export class TurtleFetchError extends Error { + readonly response: Response; + constructor(message: string, response: Response) { + super(message); + this.response = response; + } +} + +/** + * Wrapper around node-fetch adding some useful features: + * - retries + * - json body - if you specify json in options, it will be stringified and content-type will be set to application/json + * - automatic error throwing - if response is not ok, it will throw an error + * + * @param url URL to fetch + * @param method HTTP method + * @param options.retries number of retries + * @param options.json json body + * @param options.headers headers + * @param options.shouldThrowOnNotOk if false, it will not throw an error if response is not ok (default: true) + * @param options other options passed to node-fetch + * @returns {Promise} + */ +export async function turtleFetch( + url: string, + method: TurtleFetchMethod, + options: Omit & { + retries?: number; + json?: Record; + headers?: Exclude; + shouldThrowOnNotOk?: boolean; + retryIntervalMs?: number; + logger?: bunyan; + } +): Promise { + const { + json, + headers: rawHeaders, + retries: rawRetries, + logger, + retryIntervalMs = 1000, + shouldThrowOnNotOk = true, + ...otherOptions + } = options; + + const retries = rawRetries ?? (method === 'POST' ? 0 : 2); + + const body = JSON.stringify(json); + const headers = json ? { ...rawHeaders, 'Content-Type': 'application/json' } : rawHeaders; + + return await retryAsync( + async (attemptCount) => { + const response = await fetch(url, { + method, + body, + headers, + ...otherOptions, + }); + const shouldThrow = shouldThrowOnNotOk || attemptCount < retries; + if (!response.ok && shouldThrow) { + throw new TurtleFetchError(`Request failed with status ${response.status}`, response); + } + return response; + }, + { retryOptions: { retries, retryIntervalMs }, logger } + ); +} diff --git a/packages/build-tools/tsconfig.build.json b/packages/build-tools/tsconfig.build.json new file mode 100644 index 0000000000..e7b9fb5ccf --- /dev/null +++ b/packages/build-tools/tsconfig.build.json @@ -0,0 +1,8 @@ +{ + "extends": "./tsconfig.json", + "exclude": [ + "src/**/__mocks__/**/*.ts", + "src/**/__tests__/**/*.ts", + "src/**/__integration-tests__/**/*.ts" + ] +} diff --git a/packages/build-tools/tsconfig.json b/packages/build-tools/tsconfig.json new file mode 100644 index 0000000000..bcee7edd12 --- /dev/null +++ b/packages/build-tools/tsconfig.json @@ -0,0 +1,23 @@ +{ + "compilerOptions": { + "moduleResolution": "node", + "target": "es2022", + "module": "commonjs", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "declaration": true, + "composite": true, + "rootDir": "./src", + "outDir": "./dist", + "plugins": [ + { + "name": "gql.tada/ts-plugin", + "schema": "./schema.graphql", + "tadaOutputLocation": "./src/graphql-env.d.ts" + } + ] + }, + "include": ["src/**/*"] +} diff --git a/packages/create-eas-build-function/README.md b/packages/create-eas-build-function/README.md new file mode 100644 index 0000000000..94cdb71655 --- /dev/null +++ b/packages/create-eas-build-function/README.md @@ -0,0 +1,20 @@ +

+ +

Create EAS Build Function

+ +

+ +

+ The fastest way to create the function module for EAS Build custom builds +

+ +```sh +# With NPM +npx create-eas-build-function + +# With Yarn +yarn create eas-build-function + +# With pnpm +pnpm create eas-build-function +``` diff --git a/packages/create-eas-build-function/jest.config.cjs b/packages/create-eas-build-function/jest.config.cjs new file mode 100644 index 0000000000..65626757ef --- /dev/null +++ b/packages/create-eas-build-function/jest.config.cjs @@ -0,0 +1,23 @@ +module.exports = { + preset: 'ts-jest/presets/default-esm', + transform: { + '^.+\\.tsx?$': [ + 'ts-jest', + { + tsconfig: 'tsconfig.json', + useESM: true, + }, + ], + }, + testEnvironment: 'node', + rootDir: 'src', + testMatch: ['**/__tests__/*-test.ts'], + coverageReporters: ['json', 'lcov'], + coverageDirectory: '../coverage/tests/', + collectCoverageFrom: ['**/*.ts'], + moduleNameMapper: { + '^(\\.\\.?/.*)\\.js$': ['$1.ts', '$0'], + }, + clearMocks: true, + setupFilesAfterEnv: ['/../jest/setup-tests.ts'], +}; diff --git a/packages/create-eas-build-function/package.json b/packages/create-eas-build-function/package.json new file mode 100644 index 0000000000..3afc5dcfc8 --- /dev/null +++ b/packages/create-eas-build-function/package.json @@ -0,0 +1,60 @@ +{ + "name": "create-eas-build-function", + "repository": { + "type": "git", + "url": "https://github.com/expo/eas-cli.git", + "directory": "packages/create-eas-build-function" + }, + "version": "1.0.260", + "bin": { + "create-eas-build-function": "./build/index.js" + }, + "main": "build", + "description": "Create functions for use in EAS Build custom builds.", + "license": "BSD-3-Clause", + "keywords": [ + "expo", + "react-native", + "react" + ], + "homepage": "https://docs.expo.dev", + "author": "Expo ", + "files": [ + "build", + "templates" + ], + "scripts": { + "prepack": "yarn run clean && yarn run build", + "lint": "eslint .", + "test": "echo 'No tests yet.'", + "watch": "yarn run build:dev -w", + "build:dev": "ncc build ./src/index.ts -o build/", + "build": "ncc build ./src/index.ts -o build/ --minify --no-cache --no-source-map-register", + "clean": "rimraf ./build/" + }, + "dependencies": { + "@expo/steps": "1.0.260" + }, + "devDependencies": { + "@expo/package-manager": "1.7.0", + "@jest/globals": "^29.7.0", + "@types/jest": "^29.5.12", + "@types/node": "20.14.2", + "@types/prompts": "^2.4.9", + "@vercel/ncc": "^0.38.1", + "chalk": "4.1.2", + "fs-extra": "^11.2.0", + "ora": "^6.3.1", + "prompts": "^2.4.2", + "ts-node": "^10.9.2", + "typescript": "^5.5.4", + "update-check": "^1.5.4" + }, + "publishConfig": { + "access": "public" + }, + "volta": { + "node": "20.14.0", + "yarn": "1.22.21" + } +} diff --git a/packages/create-eas-build-function/src/cli.ts b/packages/create-eas-build-function/src/cli.ts new file mode 100644 index 0000000000..658e347370 --- /dev/null +++ b/packages/create-eas-build-function/src/cli.ts @@ -0,0 +1,77 @@ +#!/usr/bin/env node + +import { Spec } from 'arg'; +import chalk from 'chalk'; + +import { assertWithOptionsArgs, printHelp, resolveStringOrBooleanArgsAsync } from './utils/args'; +import { Log } from './log'; +import { ExitError } from './error'; +import shouldUpdate from './utils/updateCheck'; + +async function run(): Promise { + const argv = process.argv.slice(2) ?? []; + const rawArgsMap: Spec = { + // Types + '--version': Boolean, + '--help': Boolean, + '--no-install': Boolean, + // Aliases + '-v': '--version', + '-h': '--help', + }; + const args = assertWithOptionsArgs(rawArgsMap, { + argv, + permissive: true, + }); + + if (args['--version']) { + Log.exit(require('../package.json').version, 0); + } + + if (args['--help']) { + printHelp( + `Creates EAS Build custom function module`, + chalk`npx create-eas-build-function {cyan } [options]`, + [ + ` --no-install Skip installing npm packages`, + chalk`-t, --template {gray [pkg]} NPM template to use: typescript, javascript. Default: typescript`, + `-v, --version Version number`, + `-h, --help Usage info`, + ].join('\n'), + chalk` + {gray To choose a template pass in the {bold --template} arg:} + + {gray $} npx create-eas-build-function {cyan --template} + + {gray The package manager used for installing} + {gray node modules is based on how you invoke the CLI:} + + {bold npm:} {cyan npx create-eas-build-function} + {bold yarn:} {cyan yarn create eas-custom-function-module} + {bold pnpm:} {cyan pnpm create eas-custom-function-module} + ` + ); + } + + try { + const parsed = resolveStringOrBooleanArgsAsync(argv, rawArgsMap, { + '--template': Boolean, + '-t': '--template', + }); + + const { createAsync } = await import('./createAsync'); + await createAsync(parsed.projectRoot, { + template: parsed.args['--template'], + install: !args['--no-install'], + }); + } catch (error: any) { + // ExitError has already been logged, all others should be logged before exiting. + if (!(error instanceof ExitError)) { + Log.exception(error); + } + } finally { + await shouldUpdate(); + } +} + +void run(); diff --git a/packages/create-eas-build-function/src/createAsync.ts b/packages/create-eas-build-function/src/createAsync.ts new file mode 100644 index 0000000000..7b94d5be67 --- /dev/null +++ b/packages/create-eas-build-function/src/createAsync.ts @@ -0,0 +1,118 @@ +#!/usr/bin/env node + +import path from 'path'; +import fs from 'fs'; + +import chalk from 'chalk'; + +import { extractAndPrepareTemplateFunctionModuleAsync, promptTemplateAsync } from './templates'; +import { assertFolderEmpty, assertValidName, resolveProjectRootAsync } from './resolveProjectRoot'; +import { + PackageManagerName, + installDependenciesAsync, + resolvePackageManager, +} from './resolvePackageManager'; +import { Log } from './log'; +import { withSectionLog } from './utils/log'; + +export type Options = { + install: boolean; + template?: string | true; +}; + +export async function createAsync(inputPath: string, options: Options): Promise { + let resolvedTemplate: string; + if (options.template === true) { + resolvedTemplate = await promptTemplateAsync(); + } else { + resolvedTemplate = options.template ?? 'typescript'; + } + + const projectRoot = await resolveProjectRootArgAsync(inputPath); + await fs.promises.mkdir(projectRoot, { recursive: true }); + + await withSectionLog( + () => extractAndPrepareTemplateFunctionModuleAsync(projectRoot, resolvedTemplate), + { + pending: chalk.bold('Locating project files...'), + success: 'Successfully extracted custom build function template files.', + error: (error) => + `Something went wrong when extracting the custom build function template files: ${error.message}`, + } + ); + + await setupDependenciesAsync(projectRoot, options); +} + +async function resolveProjectRootArgAsync(inputPath: string): Promise { + if (!inputPath) { + const projectRoot = path.resolve(process.cwd()); + const folderName = path.basename(projectRoot); + assertValidName(folderName); + assertFolderEmpty(projectRoot, folderName); + return projectRoot; + } else { + return await resolveProjectRootAsync(inputPath); + } +} + +async function setupDependenciesAsync( + projectRoot: string, + props: Pick +): Promise { + // Install dependencies + const shouldInstall = props.install; + const packageManager = resolvePackageManager(); + if (shouldInstall) { + await installNodeDependenciesAsync(projectRoot, packageManager); + } + const cdPath = getChangeDirectoryPath(projectRoot); + Log.log(); + logProjectReady({ cdPath }); + if (!shouldInstall) { + logNodeInstallWarning(cdPath, packageManager); + } +} + +async function installNodeDependenciesAsync( + projectRoot: string, + packageManager: PackageManagerName +): Promise { + try { + await installDependenciesAsync(projectRoot, packageManager, { silent: false }); + } catch (error: any) { + Log.error( + `Something went wrong installing JavaScript dependencies. Check your ${packageManager} logs. Continuing to create the app.` + ); + Log.exception(error); + } +} + +function getChangeDirectoryPath(projectRoot: string): string { + const cdPath = path.relative(process.cwd(), projectRoot); + if (cdPath.length <= projectRoot.length) { + return cdPath; + } + return projectRoot; +} + +function logNodeInstallWarning(cdPath: string, packageManager: PackageManagerName): void { + Log.log( + `\n⚠️ Before you start to work on your function, make sure you have modules installed:\n` + ); + Log.log(` cd ${cdPath || '.'}${path.sep}`); + Log.log(` ${packageManager} install`); + Log.log(); +} + +export function logProjectReady({ cdPath }: { cdPath: string }): void { + Log.log(chalk.bold(`✅ Your function is ready!`)); + Log.log(); + + // empty string if project was created in current directory + if (cdPath) { + Log.log(`To start working on your function, navigate to the directory.`); + Log.log(); + Log.log(`- ${chalk.bold('cd ' + cdPath)}`); + } +} diff --git a/packages/create-eas-build-function/src/error.ts b/packages/create-eas-build-function/src/error.ts new file mode 100644 index 0000000000..5164ea9c45 --- /dev/null +++ b/packages/create-eas-build-function/src/error.ts @@ -0,0 +1,14 @@ +#!/usr/bin/env node + +/** + * A custom error class that is used to surface a `process.exit` event to a higher + * level where it can be tracked through telemetry asynchronously, before exiting. + */ +export class ExitError extends Error { + constructor( + public cause: string | Error, + public code: number + ) { + super(cause instanceof Error ? cause.message : cause); + } +} diff --git a/packages/create-eas-build-function/src/index.ts b/packages/create-eas-build-function/src/index.ts new file mode 100644 index 0000000000..734f03b4bd --- /dev/null +++ b/packages/create-eas-build-function/src/index.ts @@ -0,0 +1,3 @@ +#!/usr/bin/env node + +require('./cli'); diff --git a/packages/create-eas-build-function/src/log.ts b/packages/create-eas-build-function/src/log.ts new file mode 100644 index 0000000000..03d5594d8c --- /dev/null +++ b/packages/create-eas-build-function/src/log.ts @@ -0,0 +1,44 @@ +#!/usr/bin/env node + +import chalk from 'chalk'; + +import { ExitError } from './error'; + +export function error(...message: string[]): void { + console.error(...message); +} + +/** Print an error and provide additional info (the stack trace) in debug mode. */ +export function exception(e: Error): void { + error(chalk.red(e.toString())); +} + +export function log(...message: string[]): void { + console.log(...message); +} + +/** Log a message and exit the current process. If the `code` is non-zero then `console.error` will be used instead of `console.log`. */ +export function exit(message: string | Error, code: number = 1): never { + if (message instanceof Error) { + exception(message); + } else if (message) { + if (code === 0) { + log(message); + } else { + error(message); + } + } + + if (code !== 0) { + throw new ExitError(message, code); + } + process.exit(code); +} + +// The re-export makes auto importing easier. +export const Log = { + error, + exception, + log, + exit, +}; diff --git a/packages/create-eas-build-function/src/resolvePackageManager.ts b/packages/create-eas-build-function/src/resolvePackageManager.ts new file mode 100644 index 0000000000..746973217b --- /dev/null +++ b/packages/create-eas-build-function/src/resolvePackageManager.ts @@ -0,0 +1,75 @@ +#!/usr/bin/env node + +import { execSync } from 'child_process'; + +import * as PackageManager from '@expo/package-manager'; + +export type PackageManagerName = 'npm' | 'pnpm' | 'yarn'; + +export function resolvePackageManager(): PackageManagerName { + // Attempt to detect if the user started the command using `yarn` or `pnpm` + const userAgent = process.env.npm_config_user_agent; + if (userAgent?.startsWith('yarn')) { + return 'yarn'; + } else if (userAgent?.startsWith('pnpm')) { + return 'pnpm'; + } else if (userAgent?.startsWith('npm')) { + return 'npm'; + } + + // Try availability + if (isPackageManagerAvailable('yarn')) { + return 'yarn'; + } else if (isPackageManagerAvailable('pnpm')) { + return 'pnpm'; + } + + return 'npm'; +} + +export function formatSelfCommand(): string { + const packageManager = resolvePackageManager(); + switch (packageManager) { + case 'pnpm': + return `pnpx create-eas-build-function`; + case 'yarn': + case 'npm': + default: + return `npx create-eas-build-function`; + } +} + +export function isPackageManagerAvailable(manager: PackageManagerName): boolean { + try { + execSync(`${manager} --version`, { stdio: 'ignore' }); + return true; + } catch {} + return false; +} + +export async function installDependenciesAsync( + projectRoot: string, + packageManager: PackageManagerName, + flags: { silent: boolean } = { silent: false } +): Promise { + const options = { cwd: projectRoot, silent: flags.silent }; + if (packageManager === 'yarn') { + await new PackageManager.YarnPackageManager(options).installAsync(); + } else if (packageManager === 'pnpm') { + await new PackageManager.PnpmPackageManager(options).installAsync(); + } else { + await new PackageManager.NpmPackageManager(options).installAsync(); + } +} + +export function formatRunCommand(packageManager: PackageManagerName, cmd: string): string { + switch (packageManager) { + case 'pnpm': + return `pnpm run ${cmd}`; + case 'yarn': + return `yarn ${cmd}`; + case 'npm': + default: + return `npm run ${cmd}`; + } +} diff --git a/packages/create-eas-build-function/src/resolveProjectRoot.ts b/packages/create-eas-build-function/src/resolveProjectRoot.ts new file mode 100644 index 0000000000..fb4517bd6d --- /dev/null +++ b/packages/create-eas-build-function/src/resolveProjectRoot.ts @@ -0,0 +1,108 @@ +#!/usr/bin/env node + +import path from 'path'; +import fs from 'fs'; + +import chalk from 'chalk'; +import prompts from 'prompts'; + +import { Log } from './log'; +import { getConflictsForDirectory } from './utils/dir'; +import { formatSelfCommand } from './resolvePackageManager'; + +export function assertFolderEmpty(projectRoot: string, folderName: string): void { + const conflicts = getConflictsForDirectory(projectRoot); + if (conflicts.length) { + Log.log(chalk`The directory {cyan ${folderName}} has files that might be overwritten:`); + Log.log(); + for (const file of conflicts) { + Log.log(` ${file}`); + } + Log.log(); + Log.exit('Try using a new directory name, or moving these files.\n'); + } +} + +const FORBIDDEN_NAMES = [ + 'react-native', + 'react', + 'react-dom', + 'react-native-web', + 'expo', + 'expo-router', +]; + +export function assertValidName(folderName: string): void { + const validation = validateName(folderName); + if (typeof validation === 'string') { + Log.exit(chalk`{red Cannot create an app named {bold "${folderName}"}. ${validation}}`, 1); + } + const isForbidden = isFolderNameForbidden(folderName); + if (isForbidden) { + Log.exit( + chalk`{red Cannot create an app named {bold "${folderName}"} because it would conflict with a dependency of the same name.}`, + 1 + ); + } +} + +export async function resolveProjectRootAsync(input: string): Promise { + let name = input?.trim(); + + if (!name) { + const { answer } = await prompts({ + type: 'text', + name: 'answer', + message: 'What is your EAS Build function named?', + initial: 'my-function', + validate: (name) => { + const validation = validateName(path.basename(path.resolve(name))); + if (typeof validation === 'string') { + return 'Invalid project name: ' + validation; + } + return true; + }, + }); + + if (typeof answer === 'string') { + name = answer.trim(); + } + } + + if (!name) { + const selfCmd = formatSelfCommand(); + Log.log(); + Log.log('Please choose your app name:'); + Log.log(chalk` {dim $} {cyan ${selfCmd} }`); + Log.log(); + Log.log(`For more info, run:`); + Log.log(chalk` {dim $} {cyan ${selfCmd} --help}`); + Log.log(); + Log.exit(''); + } + + const projectRoot = path.resolve(name); + const folderName = path.basename(projectRoot); + + assertValidName(folderName); + + await fs.promises.mkdir(projectRoot, { recursive: true }); + + assertFolderEmpty(projectRoot, folderName); + + return projectRoot; +} + +function validateName(name?: string): string | true { + if (typeof name !== 'string' || name === '') { + return 'The project name can not be empty.'; + } + if (!/^[a-z0-9@.\-_]+$/i.test(name)) { + return 'The project name can only contain URL-friendly characters.'; + } + return true; +} + +function isFolderNameForbidden(folderName: string): boolean { + return FORBIDDEN_NAMES.includes(folderName); +} diff --git a/packages/create-eas-build-function/src/templates.ts b/packages/create-eas-build-function/src/templates.ts new file mode 100644 index 0000000000..76c53fe7a7 --- /dev/null +++ b/packages/create-eas-build-function/src/templates.ts @@ -0,0 +1,82 @@ +#!/usr/bin/env node + +import path from 'path'; + +import fs from 'fs-extra'; +import chalk from 'chalk'; +import prompts from 'prompts'; + +import { Log } from './log'; + +export const TEMPLATES = [ + { + title: 'TypeScript', + value: 'typescript', + description: 'a minimal module written in TypeScript', + }, + + { + title: 'JavaScript', + value: 'javascript', + description: 'a minimal module written in JavaScript', + }, +]; + +export const ALIASES = TEMPLATES.map(({ value }) => value); + +export async function promptTemplateAsync(): Promise<'typescript' | 'javascript'> { + const { answer } = await prompts({ + type: 'select', + name: 'answer', + message: 'Choose a template:', + choices: TEMPLATES, + }); + + if (!answer) { + Log.log(); + Log.log(chalk`Please specify the template, example: {cyan --template typescript}`); + Log.log(); + process.exit(1); + } + + return answer; +} + +/** + * Extract a template app to a given file path and clean up any properties left over from npm to + * prepare it for usage. + */ +export async function extractAndPrepareTemplateFunctionModuleAsync( + projectRoot: string, + resolvedTemplate: string +): Promise { + await copyTemplateAsync(resolvedTemplate, { + cwd: projectRoot, + }); + + return projectRoot; +} + +export async function copyTemplateAsync( + resolvedTemplate: string, + props: { + cwd: string; + } +): Promise { + const modulePath = path.resolve(__dirname, '../templates', resolvedTemplate); + try { + await copyDir(modulePath, props.cwd); + } catch (error: any) { + Log.error('Error extracting template package: ' + resolvedTemplate); + throw error; + } +} + +async function copyDir(src: string, dest: string): Promise { + try { + await fs.copy(src, dest); + } catch (error: any) { + Log.error(error); + throw error; + } +} diff --git a/packages/create-eas-build-function/src/utils/args.ts b/packages/create-eas-build-function/src/utils/args.ts new file mode 100644 index 0000000000..403b6b8462 --- /dev/null +++ b/packages/create-eas-build-function/src/utils/args.ts @@ -0,0 +1,162 @@ +#!/usr/bin/env node + +import arg, { Spec } from 'arg'; +import chalk from 'chalk'; + +import { Log } from '../log'; + +import { replaceValue } from './array'; + +/** + * Parse args and assert unknown options. + * + * @param schema the `args` schema for parsing the command line arguments. + * @param argv extra strings + * @returns processed args object. + */ +export function assertWithOptionsArgs( + schema: arg.Spec, + options: arg.Options +): arg.Result { + try { + return arg(schema, options); + } catch (error: any) { + // Ensure unknown options are handled the same way. + if (error.code === 'ARG_UNKNOWN_OPTION') { + Log.exit(error.message, 1); + } + // Otherwise rethrow the error. + throw error; + } +} + +export function printHelp(info: string, usage: string, options: string, extra: string = ''): void { + Log.exit( + chalk` + {bold Info} + ${info} + + {bold Usage} + {dim $} ${usage} + + {bold Options} + ${options.split('\n').join('\n ')} + ` + extra, + 0 + ); +} + +/** + * Enables the resolution of arguments that can either be a string or a boolean. + * + * @param args arguments that were passed to the command. + * @param rawMap raw map of arguments that are passed to the command. + * @param extraArgs extra arguments and aliases that should be resolved as string or boolean. + * @returns parsed arguments and project root. + */ +export function resolveStringOrBooleanArgsAsync( + args: string[], + rawMap: arg.Spec, + extraArgs: arg.Spec +): { + args: Record; + projectRoot: string; +} { + const combined = { + ...rawMap, + ...extraArgs, + }; + // Assert any missing arguments + assertUnknownArgs(combined, args); + + // Collapse aliases into fully qualified arguments. + args = collapseAliases(combined, args); + // Resolve all of the string or boolean arguments and the project root. + return _resolveStringOrBooleanArgs(extraArgs, args); +} + +export function _resolveStringOrBooleanArgs( + multiTypeArgs: Spec, + args: string[] +): { + args: Record; + projectRoot: string; +} { + // Default project root, if a custom one is defined then it will overwrite this. + let projectRoot: string = ''; + // The resolved arguments. + const settings: Record = {}; + + // Create a list of possible arguments, this will filter out aliases. + const possibleArgs = Object.entries(multiTypeArgs) + .filter(([, value]) => typeof value !== 'string') + .map(([key]) => key); + + // Loop over arguments in reverse order so we can resolve if a value belongs to a flag. + for (let i = args.length - 1; i > -1; i--) { + const value = args[i]; + // At this point we should have converted all aliases to fully qualified arguments. + if (value.startsWith('--')) { + // If we ever find an argument then it must be a boolean because we are checking in reverse + // and removing arguments from the array if we find a string. + settings[value] = true; + } else { + // Get the previous argument in the array. + const nextValue = i > 0 ? args[i - 1] : null; + if (nextValue && possibleArgs.includes(nextValue)) { + settings[nextValue] = value; + i--; + } else if ( + // Prevent finding two values that are dangling + !projectRoot && + // If the last value is not a flag and it doesn't have a recognized flag before it (instead having a string value or nothing) + // then it must be the project root. + (i === args.length - 1 || i === 0) + ) { + projectRoot = value; + } else { + // This will asserts if two strings are passed in a row and not at the end of the line. + throw new Error(`Unknown argument: ${value}`); + } + } + } + + return { + args: settings, + projectRoot, + }; +} + +/** Convert all aliases to fully qualified flag names. */ +export function collapseAliases(arg: Spec, args: string[]): string[] { + const aliasMap = getAliasTuples(arg); + + for (const [arg, alias] of aliasMap) { + args = replaceValue(args, arg, alias); + } + + // Assert if there are duplicate flags after we collapse the aliases. + assertDuplicateArgs(args, aliasMap); + return args; +} + +/** Assert that the spec has unknown arguments. */ +export function assertUnknownArgs(arg: Spec, args: string[]): void { + const allowedArgs = Object.keys(arg); + const unknownArgs = args.filter((arg) => !allowedArgs.includes(arg) && arg.startsWith('-')); + if (unknownArgs.length > 0) { + throw new Error(`Unknown arguments: ${unknownArgs.join(', ')}`); + } +} + +function getAliasTuples(arg: Spec): [string, string][] { + return Object.entries(arg).filter(([, value]) => typeof value === 'string') as [string, string][]; +} +/** Asserts that a duplicate flag has been used, this naively throws without knowing if an alias or flag were used as the duplicate. */ +export function assertDuplicateArgs(args: string[], argNameAliasTuple: [string, string][]): void { + for (const [argName, argNameAlias] of argNameAliasTuple) { + if (args.filter((a) => [argName, argNameAlias].includes(a)).length > 1) { + throw new Error(`Can only provide one instance of ${argName} or ${argNameAlias}`); + } + } +} diff --git a/packages/create-eas-build-function/src/utils/array.ts b/packages/create-eas-build-function/src/utils/array.ts new file mode 100644 index 0000000000..ab4b07c79f --- /dev/null +++ b/packages/create-eas-build-function/src/utils/array.ts @@ -0,0 +1,9 @@ +#!/usr/bin/env node + +export function replaceValue(values: T[], original: T, replacement: T): T[] { + const index = values.indexOf(original); + if (index > -1) { + values[index] = replacement; + } + return values; +} diff --git a/packages/create-eas-build-function/src/utils/dir.ts b/packages/create-eas-build-function/src/utils/dir.ts new file mode 100644 index 0000000000..3a38b7a6b5 --- /dev/null +++ b/packages/create-eas-build-function/src/utils/dir.ts @@ -0,0 +1,29 @@ +#!/usr/bin/env node + +import { readdirSync } from 'fs'; + +// Any of these files are allowed to exist in the projectRoot +const tolerableFiles = [ + // System + '.DS_Store', + 'Thumbs.db', + // Git + '.git', + '.gitattributes', + '.gitignore', + // Project + '.npmignore', + 'LICENSE', + 'docs', + '.idea', + // Package manager + 'npm-debug.log', + 'yarn-debug.log', + 'yarn-error.log', +]; + +export function getConflictsForDirectory(projectRoot: string): string[] { + return readdirSync(projectRoot).filter( + (file: string) => !(file.endsWith('.iml') || tolerableFiles.includes(file)) + ); +} diff --git a/packages/create-eas-build-function/src/utils/log.ts b/packages/create-eas-build-function/src/utils/log.ts new file mode 100644 index 0000000000..47b6e18390 --- /dev/null +++ b/packages/create-eas-build-function/src/utils/log.ts @@ -0,0 +1,31 @@ +#!/usr/bin/env node + +import ora, { Ora } from 'ora'; + +export function withSectionLog( + action: (spinner: Ora) => Promise, + message: { + pending: string; + success: string; + error: (errror: Error) => string; + } +): Promise { + const spinner = ora({ + text: message.pending, + // In non-interactive mode, send the stream to stdout so it prevents looking like an error. + stream: process.stderr, + }); + + spinner.start(); + + return action(spinner).then( + (result) => { + spinner.succeed(message.success); + return result; + }, + (error) => { + spinner.fail(message.error(error)); + throw error; + } + ); +} diff --git a/packages/create-eas-build-function/src/utils/updateCheck.ts b/packages/create-eas-build-function/src/utils/updateCheck.ts new file mode 100644 index 0000000000..9693aca6f4 --- /dev/null +++ b/packages/create-eas-build-function/src/utils/updateCheck.ts @@ -0,0 +1,20 @@ +#!/usr/bin/env node + +import chalk from 'chalk'; +import checkForUpdate from 'update-check'; + +import { Log } from '../log'; + +const packageJson = require('../package.json'); + +export default async function shouldUpdate(): Promise { + try { + const res = await checkForUpdate(packageJson); + if (res?.latest) { + Log.log(); + Log.log(chalk.yellow.bold(`A new version of \`${packageJson.name}\` is available`)); + Log.log(chalk`You can update by running: {cyan npm install -g ${packageJson.name}}`); + Log.log(); + } + } catch {} +} diff --git a/packages/create-eas-build-function/templates/javascript/.babelrc b/packages/create-eas-build-function/templates/javascript/.babelrc new file mode 100644 index 0000000000..c4d39e7491 --- /dev/null +++ b/packages/create-eas-build-function/templates/javascript/.babelrc @@ -0,0 +1,13 @@ +{ + "presets": [ + [ + "@babel/preset-env", + { + "targets": { + "node": "current" + }, + "modules": "commonjs" + } + ] + ] +} diff --git a/packages/create-eas-build-function/templates/javascript/README.md b/packages/create-eas-build-function/templates/javascript/README.md new file mode 100644 index 0000000000..804f2ecc24 --- /dev/null +++ b/packages/create-eas-build-function/templates/javascript/README.md @@ -0,0 +1,13 @@ +# This is your JavaScript function for EAS Build custom builds 🎉 + +## What is this? 👀 + +This is a custom build function that can be used as a step in the EAS Build build process. If you don't know what custom builds are or how to use them, refer to the [custom builds documentation](https://docs.expo.dev/custom-builds/get-started/). + +This is a way of executing your custom JavaScript code as a part of your build process running on EAS servers. + +## How can I use it? 🚀 + +Please refer to [this tutorial](https://docs.expo.dev/custom-builds/functions/) to learn how to use this function in your EAS Build custom build process. + +Check out the [custom build's **config.yml** schema reference](https://docs.expo.dev/custom-builds/schema/) to learn more! diff --git a/packages/create-eas-build-function/templates/javascript/package.json b/packages/create-eas-build-function/templates/javascript/package.json new file mode 100644 index 0000000000..fa798d6f48 --- /dev/null +++ b/packages/create-eas-build-function/templates/javascript/package.json @@ -0,0 +1,16 @@ +{ + "name": "custom-js-function", + "version": "1.0.0", + "main": "./build/index.js", + "type": "commonjs", + "devDependencies": { + "@vercel/ncc": "^0.38.1", + "@babel/cli": "^7.24.8", + "@babel/core": "^7.24.9", + "@babel/preset-env": "^7.24.8" + }, + "scripts": { + "babel": "babel src --out-dir babel", + "build": "yarn babel && ncc build ./babel/index.js -o build/ --minify --no-cache --no-source-map-register" + } +} diff --git a/packages/create-eas-build-function/templates/javascript/src/index.js b/packages/create-eas-build-function/templates/javascript/src/index.js new file mode 100644 index 0000000000..54fd98b53e --- /dev/null +++ b/packages/create-eas-build-function/templates/javascript/src/index.js @@ -0,0 +1,8 @@ +// This file was autogenerated by `create-eas-build-function` command. +// Go to README.md to learn more about how to write your own custom build functions. + +function myFunction(ctx, { inputs, outputs, env }) { + ctx.logger.info('Hello from my JavaScript function!'); +} + +export default myFunction; diff --git a/packages/create-eas-build-function/templates/typescript/README.md b/packages/create-eas-build-function/templates/typescript/README.md new file mode 100644 index 0000000000..8d22f64dab --- /dev/null +++ b/packages/create-eas-build-function/templates/typescript/README.md @@ -0,0 +1,13 @@ +# This is your TypeScript function for EAS Build custom builds 🎉 + +## What is this? 👀 + +This is a custom build function that can be used as a step in the EAS Build build process. If you don't know what custom builds are or how to use them, refer to the [custom builds documentation](https://docs.expo.dev/custom-builds/get-started/). + +This is a way of executing your custom TypeScript code as a part of your build process running on EAS servers. + +## How can I use it? 🚀 + +Please refer to [this tutorial](https://docs.expo.dev/custom-builds/functions/) to learn how to use this function in your EAS Build custom build process. + +Check out the [custom build's **config.yml** schema reference](https://docs.expo.dev/custom-builds/schema/) to learn more! diff --git a/packages/create-eas-build-function/templates/typescript/package.json b/packages/create-eas-build-function/templates/typescript/package.json new file mode 100644 index 0000000000..5aabee21cd --- /dev/null +++ b/packages/create-eas-build-function/templates/typescript/package.json @@ -0,0 +1,18 @@ +{ + "name": "custom-ts-function", + "version": "1.0.0", + "description": "", + "main": "./build/index.js", + "type": "commonjs", + "scripts": { + "build": "ncc build ./src/index.ts -o build/ --minify --no-cache --no-source-map-register" + }, + "keywords": [], + "author": "", + "devDependencies": { + "@vercel/ncc": "^0.38.1", + "@types/node": "20.14.11", + "typescript": "^5.5.3", + "@expo/steps": "^1.0.119" + } +} diff --git a/packages/create-eas-build-function/templates/typescript/src/index.ts b/packages/create-eas-build-function/templates/typescript/src/index.ts new file mode 100644 index 0000000000..0f0019dbb3 --- /dev/null +++ b/packages/create-eas-build-function/templates/typescript/src/index.ts @@ -0,0 +1,31 @@ +// This file was autogenerated by `create-eas-build-function` command. +// Go to README.md to learn more about how to write your own custom build functions. + +import { BuildStepContext } from '@expo/steps'; + +// interface FunctionInputs { +// // specify the type of the inputs value and whether they are required here +// // example: name: BuildStepInput; +// } + +// interface FunctionOutputs { +// // specify the function outputs and whether they are required here +// // example: name: BuildStepOutput; +// } + +async function myFunction( + ctx: BuildStepContext + // { + // inputs, + // outputs, + // env, + // }: { + // inputs: FunctionInputs; + // outputs: FunctionOutputs; + // env: BuildStepEnv; + // } +): Promise { + ctx.logger.info('Hello from my TypeScript function!'); +} + +export default myFunction; diff --git a/packages/create-eas-build-function/templates/typescript/tsconfig.json b/packages/create-eas-build-function/templates/typescript/tsconfig.json new file mode 100644 index 0000000000..8d9ff7174a --- /dev/null +++ b/packages/create-eas-build-function/templates/typescript/tsconfig.json @@ -0,0 +1,26 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "commonjs", + "sourceMap": true, + "inlineSources": true, + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true, + "esModuleInterop": true, + "skipLibCheck": true, + "noEmit": false, + "forceConsistentCasingInFileNames": true, + "outDir": "build", + "resolveJsonModule": true, + "allowSyntheticDefaultImports": true, + "moduleResolution": "node", + "rootDir": "src", + "declaration": false, + "composite": false + }, + "include": ["src/**/*.ts"], + "exclude": ["**/__mocks__/*", "**/__tests__/*"] +} diff --git a/packages/create-eas-build-function/tsconfig.json b/packages/create-eas-build-function/tsconfig.json new file mode 100644 index 0000000000..3a1729cbec --- /dev/null +++ b/packages/create-eas-build-function/tsconfig.json @@ -0,0 +1,19 @@ +{ + "include": ["src/**/*.ts"], + "compilerOptions": { + "moduleResolution": "node", + "target": "es2022", + "module": "commonjs", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "outDir": "build", + "resolveJsonModule": true, + "allowSyntheticDefaultImports": true, + "rootDir": "src", + "declaration": true, + "composite": true + }, + "exclude": ["**/__mocks__/*", "**/__tests__/*"] +} diff --git a/packages/downloader/.eslintrc.json b/packages/downloader/.eslintrc.json new file mode 100644 index 0000000000..be97c53fbb --- /dev/null +++ b/packages/downloader/.eslintrc.json @@ -0,0 +1,3 @@ +{ + "extends": "../../.eslintrc.json" +} diff --git a/packages/downloader/.gitignore b/packages/downloader/.gitignore new file mode 100644 index 0000000000..b947077876 --- /dev/null +++ b/packages/downloader/.gitignore @@ -0,0 +1,2 @@ +node_modules/ +dist/ diff --git a/packages/downloader/README.md b/packages/downloader/README.md new file mode 100644 index 0000000000..0ec2f85377 --- /dev/null +++ b/packages/downloader/README.md @@ -0,0 +1,7 @@ +# @expo/downloader + +`@expo/downloader` is a small helper library used to download files. + +## Repository + +https://github.com/expo/eas-cli/tree/main/packages/downloader diff --git a/packages/downloader/jest.config.js b/packages/downloader/jest.config.js new file mode 100644 index 0000000000..97c059fc2b --- /dev/null +++ b/packages/downloader/jest.config.js @@ -0,0 +1,8 @@ +module.exports = { + preset: 'ts-jest', + testEnvironment: 'node', + rootDir: 'src', + testMatch: ['**/__tests__/*.test.ts'], + clearMocks: true, + setupFilesAfterEnv: ['/../jest/setup-tests.ts'], +}; diff --git a/packages/downloader/jest/setup-tests.ts b/packages/downloader/jest/setup-tests.ts new file mode 100644 index 0000000000..e367ec79ab --- /dev/null +++ b/packages/downloader/jest/setup-tests.ts @@ -0,0 +1,5 @@ +if (process.env.NODE_ENV !== 'test') { + throw new Error("NODE_ENV environment variable must be set to 'test'."); +} + +// Always mock: diff --git a/packages/downloader/package.json b/packages/downloader/package.json new file mode 100644 index 0000000000..2c9758edb6 --- /dev/null +++ b/packages/downloader/package.json @@ -0,0 +1,42 @@ +{ + "name": "@expo/downloader", + "repository": { + "type": "git", + "url": "https://github.com/expo/eas-cli.git", + "directory": "packages/downloader" + }, + "version": "1.0.260", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "files": [ + "dist" + ], + "scripts": { + "start": "yarn watch", + "watch": "tsc --watch --preserveWatchOutput", + "build": "tsc", + "prepack": "rm -rf dist && tsc -p tsconfig.build.json", + "test": "jest --config jest.config.js", + "test:watch": "jest --config jest.config.js --watch", + "clean": "rm -rf node_modules dist coverage" + }, + "author": "Expo ", + "bugs": "https://github.com/expo/eas-cli/issues", + "license": "BUSL-1.1", + "dependencies": { + "fs-extra": "^11.2.0", + "got": "11.8.5" + }, + "devDependencies": { + "@types/fs-extra": "^11.0.4", + "@types/jest": "^29.5.12", + "@types/node": "20.14.2", + "jest": "^29.7.0", + "ts-jest": "^29.1.4", + "typescript": "^5.5.4" + }, + "volta": { + "node": "20.14.0", + "yarn": "1.22.21" + } +} diff --git a/packages/downloader/src/__tests__/index.test.ts b/packages/downloader/src/__tests__/index.test.ts new file mode 100644 index 0000000000..c04350d16d --- /dev/null +++ b/packages/downloader/src/__tests__/index.test.ts @@ -0,0 +1,56 @@ +import fs from 'fs-extra'; + +import downloadFile from '../index'; + +const tmpPathLocation = '/tmp/turtle-v2-downloader-test'; + +const fileInPublicS3Bucket = + 'https://turtle-v2-test-fixtures.s3.us-east-2.amazonaws.com/project.tar.gz'; +const missingFileInS3Bucket = + 'https://turtle-v2-test-fixtures.s3.us-east-2.amazonaws.com/project123.tar.gz'; + +describe('downloadFile', () => { + beforeEach(async () => { + await fs.remove(tmpPathLocation); + }); + + afterAll(async () => { + await fs.remove(tmpPathLocation); + }); + + it('should download file', async () => { + await downloadFile(fileInPublicS3Bucket, tmpPathLocation, { timeout: 2000 }); + const fileExists = await fs.pathExists(tmpPathLocation); + expect(fileExists).toBe(true); + }); + + it('should throw error when 4xx', async () => { + await expect( + downloadFile(missingFileInS3Bucket, tmpPathLocation, { timeout: 2000 }) + ).rejects.toThrow(); + }); + + it('should throw error when host unreachable', async () => { + await expect( + downloadFile('https://amazonawswueytfgweuyfgvuwefvuweyvf.com', tmpPathLocation, { + timeout: 2000, + }) + ).rejects.toThrow(); + }); + + it('should throw error when timeout is reached', async () => { + await expect( + downloadFile(fileInPublicS3Bucket, tmpPathLocation, { timeout: 1 }) + ).rejects.toThrow(); + }); + + it('should cleanup file on error', async () => { + try { + await downloadFile(missingFileInS3Bucket, tmpPathLocation, { timeout: 1 }); + } catch { + /* empty block statement */ + } + const fileExists = await fs.pathExists(tmpPathLocation); + expect(fileExists).toBe(false); + }); +}); diff --git a/packages/downloader/src/index.ts b/packages/downloader/src/index.ts new file mode 100644 index 0000000000..36d8bd91cc --- /dev/null +++ b/packages/downloader/src/index.ts @@ -0,0 +1,31 @@ +import stream from 'stream'; +import { promisify } from 'util'; + +import fs from 'fs-extra'; +import got from 'got'; + +const pipeline = promisify(stream.pipeline); + +async function downloadFile( + srcUrl: string, + outputPath: string, + { retry, timeout }: { retry?: number; timeout?: number } +): Promise { + let attemptCount = 0; + for (;;) { + attemptCount += 1; + try { + await pipeline(got.stream(srcUrl, { timeout }), fs.createWriteStream(outputPath)); + return; + } catch (err: any) { + if (await fs.pathExists(outputPath)) { + await fs.remove(outputPath); + } + if (attemptCount > (retry ?? 0)) { + throw new Error(`Failed to download the file: ${err?.message}\n${err?.stack}`); + } + } + } +} + +export default downloadFile; diff --git a/packages/downloader/tsconfig.build.json b/packages/downloader/tsconfig.build.json new file mode 100644 index 0000000000..2ef7345294 --- /dev/null +++ b/packages/downloader/tsconfig.build.json @@ -0,0 +1,4 @@ +{ + "extends": "./tsconfig.json", + "exclude": ["src/**/__mocks__/**/*.ts", "src/**/__tests__/**/*.ts"] +} diff --git a/packages/downloader/tsconfig.json b/packages/downloader/tsconfig.json new file mode 100644 index 0000000000..88c1c45a97 --- /dev/null +++ b/packages/downloader/tsconfig.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "moduleResolution": "node", + "target": "es2022", + "module": "commonjs", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "declaration": true, + "composite": true, + "rootDir": "./src", + "outDir": "./dist" + }, + "include": [ + "src/**/*" + ] +} diff --git a/packages/eas-build-job/.eslintrc.json b/packages/eas-build-job/.eslintrc.json new file mode 100644 index 0000000000..be97c53fbb --- /dev/null +++ b/packages/eas-build-job/.eslintrc.json @@ -0,0 +1,3 @@ +{ + "extends": "../../.eslintrc.json" +} diff --git a/packages/eas-build-job/.gitignore b/packages/eas-build-job/.gitignore new file mode 100644 index 0000000000..b947077876 --- /dev/null +++ b/packages/eas-build-job/.gitignore @@ -0,0 +1,2 @@ +node_modules/ +dist/ diff --git a/packages/eas-build-job/README.md b/packages/eas-build-job/README.md new file mode 100644 index 0000000000..3a6524bafa --- /dev/null +++ b/packages/eas-build-job/README.md @@ -0,0 +1,7 @@ +# @expo/eas-build-job + +`@expo/eas-build-job` contains types and Joi schemas for job objects. It's used in `eas-cli` to create the job object and on EAS Build workers (and in `eas-cli-local-build-plugin`) to process the job and run the build. + +## Repository + +https://github.com/expo/eas-cli/tree/main/packages/eas-build-job diff --git a/packages/eas-build-job/jest.config.js b/packages/eas-build-job/jest.config.js new file mode 100644 index 0000000000..97c059fc2b --- /dev/null +++ b/packages/eas-build-job/jest.config.js @@ -0,0 +1,8 @@ +module.exports = { + preset: 'ts-jest', + testEnvironment: 'node', + rootDir: 'src', + testMatch: ['**/__tests__/*.test.ts'], + clearMocks: true, + setupFilesAfterEnv: ['/../jest/setup-tests.ts'], +}; diff --git a/packages/eas-build-job/jest/setup-tests.ts b/packages/eas-build-job/jest/setup-tests.ts new file mode 100644 index 0000000000..e367ec79ab --- /dev/null +++ b/packages/eas-build-job/jest/setup-tests.ts @@ -0,0 +1,5 @@ +if (process.env.NODE_ENV !== 'test') { + throw new Error("NODE_ENV environment variable must be set to 'test'."); +} + +// Always mock: diff --git a/packages/eas-build-job/package.json b/packages/eas-build-job/package.json new file mode 100644 index 0000000000..5e5603b9ba --- /dev/null +++ b/packages/eas-build-job/package.json @@ -0,0 +1,43 @@ +{ + "name": "@expo/eas-build-job", + "repository": { + "type": "git", + "url": "https://github.com/expo/eas-cli.git", + "directory": "packages/eas-build-job" + }, + "version": "1.0.260", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "files": [ + "dist" + ], + "scripts": { + "start": "yarn watch", + "watch": "tsc --watch --preserveWatchOutput", + "build": "tsc", + "prepack": "rm -rf dist && tsc -p tsconfig.build.json", + "test": "jest --config jest.config.js", + "test:watch": "jest --config jest.config.js --watch", + "clean": "rm -rf node_modules dist coverage" + }, + "author": "Expo ", + "bugs": "https://github.com/expo/eas-cli/issues", + "license": "MIT", + "devDependencies": { + "@types/jest": "^29.5.12", + "@types/node": "20.14.2", + "jest": "^29.7.0", + "ts-jest": "^29.1.4", + "typescript": "^5.5.4" + }, + "dependencies": { + "@expo/logger": "1.0.260", + "joi": "^17.13.1", + "semver": "^7.6.2", + "zod": "^4.1.3" + }, + "volta": { + "node": "20.14.0", + "yarn": "1.22.21" + } +} diff --git a/packages/eas-build-job/src/__tests__/android.test.ts b/packages/eas-build-job/src/__tests__/android.test.ts new file mode 100644 index 0000000000..71150efd8e --- /dev/null +++ b/packages/eas-build-job/src/__tests__/android.test.ts @@ -0,0 +1,406 @@ +import { randomUUID } from 'crypto'; + +import Joi from 'joi'; +import { LoggerLevel } from '@expo/logger'; + +import * as Android from '../android'; +import { ArchiveSourceType, BuildMode, BuildTrigger, Platform, Workflow } from '../common'; + +const joiOptions: Joi.ValidationOptions = { + stripUnknown: true, + convert: true, + abortEarly: false, +}; + +const secrets = { + buildCredentials: { + keystore: { + dataBase64: 'MjEzNwo=', + keystorePassword: 'pass1', + keyAlias: 'alias', + keyPassword: 'pass2', + }, + }, +}; + +describe('Android.JobSchema', () => { + test('valid generic job', () => { + const genericJob = { + secrets, + platform: Platform.ANDROID, + type: Workflow.GENERIC, + projectArchive: { + type: ArchiveSourceType.URL, + url: 'http://localhost:3000', + }, + gradleCommand: ':app:bundleRelease', + applicationArchivePath: 'android/app/build/outputs/bundle/release/app-release.aab', + projectRootDirectory: '.', + builderEnvironment: { + image: 'default', + node: '1.2.3', + corepack: true, + yarn: '2.3.4', + ndk: '4.5.6', + bun: '1.0.0', + env: { + SOME_ENV: '123', + }, + }, + expoBuildUrl: 'https://expo.dev/fake/build/url', + initiatingUserId: randomUUID(), + appId: randomUUID(), + }; + + const { value, error } = Android.JobSchema.validate(genericJob, joiOptions); + expect(value).toMatchObject(genericJob); + expect(error).toBeFalsy(); + }); + + test('valid generic job with metadataLocation', () => { + const genericJob = { + secrets, + platform: Platform.ANDROID, + type: Workflow.GENERIC, + projectArchive: { + type: ArchiveSourceType.GCS, + bucketKey: 'path/to/file', + metadataLocation: 'path/to/metadata', + }, + gradleCommand: ':app:bundleRelease', + applicationArchivePath: 'android/app/build/outputs/bundle/release/app-release.aab', + projectRootDirectory: '.', + builderEnvironment: { + image: 'default', + node: '1.2.3', + corepack: false, + yarn: '2.3.4', + ndk: '4.5.6', + bun: '1.0.0', + env: { + SOME_ENV: '123', + }, + }, + initiatingUserId: randomUUID(), + appId: randomUUID(), + }; + + const { value, error } = Android.JobSchema.validate(genericJob, joiOptions); + expect(value).toMatchObject(genericJob); + expect(error).toBeFalsy(); + }); + + test('invalid generic job', () => { + const genericJob = { + secrets, + platform: Platform.ANDROID, + type: Workflow.GENERIC, + projectArchive: { + type: ArchiveSourceType.URL, + url: 'url', + }, + gradleCommand: 1, + uknownField: 'field', + projectRootDirectory: '.', + initiatingUserId: randomUUID(), + appId: randomUUID(), + }; + + const { value, error } = Android.JobSchema.validate(genericJob, joiOptions); + expect(error?.message).toBe( + '"projectArchive.url" must be a valid uri. "gradleCommand" must be a string' + ); + expect(value).not.toMatchObject(genericJob); + }); + + test('valid managed job', () => { + const managedJob = { + secrets, + platform: Platform.ANDROID, + type: Workflow.MANAGED, + buildType: Android.BuildType.APP_BUNDLE, + username: 'turtle-tutorial', + projectArchive: { + type: ArchiveSourceType.URL, + url: 'http://localhost:3000', + }, + projectRootDirectory: '.', + builderEnvironment: { + image: 'default', + node: '1.2.3', + yarn: '2.3.4', + ndk: '4.5.6', + bun: '1.0.0', + env: { + SOME_ENV: '123', + }, + }, + initiatingUserId: randomUUID(), + appId: randomUUID(), + }; + + const { value, error } = Android.JobSchema.validate(managedJob, joiOptions); + expect(value).toMatchObject(managedJob); + expect(error).toBeFalsy(); + }); + + test('valid job with environment', () => { + const environments = ['production', 'preview', 'development', 'staging', 'custom-env']; + + environments.forEach((env) => { + const jobWithEnvironment = { + secrets, + platform: Platform.ANDROID, + type: Workflow.GENERIC, + projectArchive: { + type: ArchiveSourceType.URL, + url: 'http://localhost:3000', + }, + gradleCommand: ':app:bundleRelease', + applicationArchivePath: 'android/app/build/outputs/bundle/release/app-release.aab', + projectRootDirectory: '.', + builderEnvironment: { + image: 'default', + node: '1.2.3', + corepack: true, + yarn: '2.3.4', + ndk: '4.5.6', + bun: '1.0.0', + env: { + SOME_ENV: '123', + }, + }, + expoBuildUrl: 'https://expo.dev/fake/build/url', + initiatingUserId: randomUUID(), + appId: randomUUID(), + environment: env, + }; + + const { value, error } = Android.JobSchema.validate(jobWithEnvironment, joiOptions); + expect(error).toBeFalsy(); + expect(value.environment).toBe(env); + }); + }); + + test('invalid managed job', () => { + const managedJob = { + secrets, + platform: Platform.ANDROID, + type: Workflow.MANAGED, + buildType: Android.BuildType.APP_BUNDLE, + username: 3, + projectArchive: { + type: ArchiveSourceType.URL, + url: 'url', + }, + projectRootDirectory: '.', + initiatingUserId: randomUUID(), + appId: randomUUID(), + uknownField: 'field', + }; + + const { value, error } = Android.JobSchema.validate(managedJob, joiOptions); + expect(error?.message).toBe( + '"projectArchive.url" must be a valid uri. "username" must be a string' + ); + expect(value).not.toMatchObject(managedJob); + }); + + test('validates channel', () => { + const managedJob = { + secrets, + type: Workflow.MANAGED, + platform: Platform.ANDROID, + updates: { + channel: 'main', + }, + projectArchive: { + type: ArchiveSourceType.URL, + url: 'http://localhost:3000', + }, + projectRootDirectory: '.', + initiatingUserId: randomUUID(), + appId: randomUUID(), + }; + + const { value, error } = Android.JobSchema.validate(managedJob, joiOptions); + expect(value).toMatchObject(managedJob); + expect(error).toBeFalsy(); + }); + + test('build from git without buildProfile defined', () => { + const managedJob = { + secrets, + triggeredBy: BuildTrigger.GIT_BASED_INTEGRATION, + platform: Platform.ANDROID, + type: Workflow.MANAGED, + buildType: Android.BuildType.APP_BUNDLE, + username: 'turtle-tutorial', + projectArchive: { + type: ArchiveSourceType.GIT, + repositoryUrl: 'http://localhost:3000', + gitRef: 'master', + gitCommitHash: '1b57db5b1cd12638aba0d12da71a2d691416700d', + }, + projectRootDirectory: '.', + builderEnvironment: { + image: 'default', + }, + initiatingUserId: randomUUID(), + appId: randomUUID(), + }; + + const { error } = Android.JobSchema.validate(managedJob, joiOptions); + expect(error?.message).toBe('"buildProfile" is required'); + }); + + test('valid custom build job with path', () => { + const customBuildJob = { + mode: BuildMode.CUSTOM, + type: Workflow.UNKNOWN, + platform: Platform.ANDROID, + projectArchive: { + type: ArchiveSourceType.URL, + url: 'https://expo.dev/builds/123', + }, + projectRootDirectory: '.', + customBuildConfig: { + path: 'production.android.yml', + }, + initiatingUserId: randomUUID(), + appId: randomUUID(), + }; + + const { value, error } = Android.JobSchema.validate(customBuildJob, joiOptions); + expect(value).toMatchObject(customBuildJob); + expect(error).toBeFalsy(); + }); + + test('valid custom build job with steps', () => { + const customBuildJob = { + mode: BuildMode.CUSTOM, + type: Workflow.UNKNOWN, + platform: Platform.ANDROID, + projectArchive: { + type: ArchiveSourceType.URL, + url: 'https://expo.dev/builds/123', + }, + projectRootDirectory: '.', + steps: [ + { + id: 'step1', + name: 'Step 1', + run: 'echo Hello, world!', + shell: 'sh', + }, + ], + outputs: {}, + initiatingUserId: randomUUID(), + appId: randomUUID(), + workflowInterpolationContext: { + after: { + setup: { + status: 'success', + outputs: {}, + }, + }, + needs: { + setup: { + status: 'success', + outputs: {}, + }, + }, + github: { + event_name: 'push', + sha: '123', + ref: 'master', + ref_name: 'master', + ref_type: 'branch', + commit_message: 'commit message', + triggering_actor: 'johnny', + }, + workflow: { + id: randomUUID(), + name: 'Build app', + filename: 'build.yml', + url: `https://expo.dev/workflows/${randomUUID()}`, + }, + }, + }; + + const { value, error } = Android.JobSchema.validate(customBuildJob, joiOptions); + expect(value).toMatchObject(customBuildJob); + expect(error).toBeFalsy(); + }); + + test('can set github trigger options', () => { + const job = { + mode: BuildMode.BUILD, + type: Workflow.UNKNOWN, + platform: Platform.ANDROID, + projectArchive: { + type: ArchiveSourceType.URL, + url: 'https://expo.dev/builds/123', + }, + projectRootDirectory: '.', + githubTriggerOptions: { + autoSubmit: true, + submitProfile: 'default', + }, + secrets, + initiatingUserId: randomUUID(), + appId: randomUUID(), + }; + const { value, error } = Android.JobSchema.validate(job, joiOptions); + expect(value).toMatchObject(job); + expect(error).toBeFalsy(); + }); + + test('can set github trigger options', () => { + const job = { + mode: BuildMode.BUILD, + type: Workflow.UNKNOWN, + platform: Platform.ANDROID, + projectArchive: { + type: ArchiveSourceType.URL, + url: 'https://expo.dev/builds/123', + }, + projectRootDirectory: '.', + secrets, + loggerLevel: LoggerLevel.DEBUG, + initiatingUserId: randomUUID(), + appId: randomUUID(), + }; + const { value, error } = Android.JobSchema.validate(job, joiOptions); + expect(value).toMatchObject(job); + expect(error).toBeFalsy(); + }); + + test('can set build mode === repack with steps', () => { + const job = { + mode: BuildMode.REPACK, + type: Workflow.UNKNOWN, + platform: Platform.ANDROID, + steps: [ + { + id: 'step1', + name: 'Step 1', + run: 'echo Hello, world!', + shell: 'sh', + }, + ], + outputs: {}, + projectArchive: { + type: ArchiveSourceType.URL, + url: 'https://expo.dev/builds/123', + }, + projectRootDirectory: '.', + secrets, + initiatingUserId: randomUUID(), + appId: randomUUID(), + }; + const { value, error } = Android.JobSchema.validate(job, joiOptions); + expect(value).toMatchObject(job); + expect(error).toBeFalsy(); + }); +}); diff --git a/packages/eas-build-job/src/__tests__/generic.test.ts b/packages/eas-build-job/src/__tests__/generic.test.ts new file mode 100644 index 0000000000..12c1d03b58 --- /dev/null +++ b/packages/eas-build-job/src/__tests__/generic.test.ts @@ -0,0 +1,170 @@ +import { randomUUID } from 'crypto'; + +import { ZodError } from 'zod'; + +import { ArchiveSourceType, BuildTrigger, EnvironmentSecretType } from '../common'; +import { Generic } from '../generic'; + +describe('Generic.JobZ', () => { + it('accepts valid customBuildConfig.path job', () => { + const job: Generic.Job = { + projectArchive: { + type: ArchiveSourceType.GIT, + repositoryUrl: 'https://github.com/expo/expo.git', + gitCommitHash: '1234567890', + gitRef: null, + }, + steps: [ + { + id: 'step1', + name: 'Step 1', + run: 'echo Hello, world!', + shell: 'sh', + }, + ], + secrets: { + robotAccessToken: 'token', + environmentSecrets: [ + { + name: 'secret-name', + value: 'secret-value', + type: EnvironmentSecretType.STRING, + }, + ], + }, + expoDevUrl: 'https://expo.dev/accounts/name/builds/id', + builderEnvironment: { + image: 'macos-sonoma-14.5-xcode-15.4', + node: '20.15.1', + corepack: true, + env: { + KEY1: 'value1', + }, + }, + triggeredBy: BuildTrigger.GIT_BASED_INTEGRATION, + appId: randomUUID(), + initiatingUserId: randomUUID(), + }; + expect(Generic.JobZ.parse(job)).toEqual(job); + }); + + it('accepts valid steps job', () => { + const job: Generic.Job = { + projectArchive: { + type: ArchiveSourceType.GIT, + repositoryUrl: 'https://github.com/expo/expo.git', + gitCommitHash: '1234567890', + gitRef: null, + }, + steps: [ + { + id: 'step1', + name: 'Step 1', + run: 'echo Hello, world!', + shell: 'sh', + env: { + KEY1: 'value1', + }, + }, + ], + secrets: { + robotAccessToken: 'token', + environmentSecrets: [ + { + name: 'secret-name', + value: 'secret-value', + type: EnvironmentSecretType.STRING, + }, + ], + }, + expoDevUrl: 'https://expo.dev/accounts/name/builds/id', + builderEnvironment: { + image: 'macos-sonoma-14.5-xcode-15.4', + node: '20.15.1', + corepack: false, + env: { + KEY1: 'value1', + }, + }, + triggeredBy: BuildTrigger.GIT_BASED_INTEGRATION, + appId: randomUUID(), + initiatingUserId: randomUUID(), + }; + expect(Generic.JobZ.parse(job)).toEqual(job); + }); + + it('errors when steps are not provided', () => { + const job: Omit = { + projectArchive: { + type: ArchiveSourceType.GIT, + repositoryUrl: 'https://github.com/expo/expo.git', + gitCommitHash: '1234567890', + gitRef: null, + }, + secrets: { + robotAccessToken: 'token', + environmentSecrets: [ + { + name: 'secret-name', + value: 'secret-value', + type: EnvironmentSecretType.STRING, + }, + ], + }, + expoDevUrl: 'https://expo.dev/accounts/name/builds/id', + builderEnvironment: { + image: 'macos-sonoma-14.5-xcode-15.4', + node: '20.15.1', + env: { + KEY1: 'value1', + }, + }, + triggeredBy: BuildTrigger.GIT_BASED_INTEGRATION, + appId: randomUUID(), + initiatingUserId: randomUUID(), + }; + expect(() => Generic.JobZ.parse(job)).toThrow(ZodError); + }); + + it('accepts none archive source type', () => { + const job: Generic.Job = { + projectArchive: { + type: ArchiveSourceType.NONE, + }, + steps: [ + { + id: 'step1', + name: 'Step 1', + run: 'echo Hello, world!', + shell: 'sh', + env: { + KEY1: 'value1', + }, + }, + ], + secrets: { + robotAccessToken: 'token', + environmentSecrets: [ + { + name: 'secret-name', + value: 'secret-value', + type: EnvironmentSecretType.STRING, + }, + ], + }, + expoDevUrl: 'https://expo.dev/accounts/name/builds/id', + builderEnvironment: { + image: 'macos-sonoma-14.5-xcode-15.4', + node: '20.15.1', + corepack: false, + env: { + KEY1: 'value1', + }, + }, + triggeredBy: BuildTrigger.GIT_BASED_INTEGRATION, + appId: randomUUID(), + initiatingUserId: randomUUID(), + }; + expect(Generic.JobZ.parse(job)).toEqual(job); + }); +}); diff --git a/packages/eas-build-job/src/__tests__/ios.test.ts b/packages/eas-build-job/src/__tests__/ios.test.ts new file mode 100644 index 0000000000..ec6cf65246 --- /dev/null +++ b/packages/eas-build-job/src/__tests__/ios.test.ts @@ -0,0 +1,439 @@ +import { randomUUID } from 'crypto'; + +import Joi from 'joi'; +import { LoggerLevel } from '@expo/logger'; + +import { ArchiveSourceType, BuildMode, Platform, Workflow } from '../common'; +import * as Ios from '../ios'; + +const joiOptions: Joi.ValidationOptions = { + stripUnknown: true, + convert: true, + abortEarly: false, +}; + +const buildCredentials: Ios.BuildCredentials = { + testapp: { + distributionCertificate: { + dataBase64: 'YmluYXJ5Y29udGVudDE=', + password: 'distCertPassword', + }, + provisioningProfileBase64: 'MnRuZXRub2N5cmFuaWI=', + }, +}; + +describe('Ios.JobSchema', () => { + test('valid generic job', () => { + const genericJob = { + secrets: { + buildCredentials, + }, + type: Workflow.GENERIC, + platform: Platform.IOS, + projectArchive: { + type: ArchiveSourceType.URL, + url: 'http://localhost:3000', + }, + projectRootDirectory: '.', + scheme: 'testapp', + buildConfiguration: 'Release', + applicationArchivePath: 'ios/build/*.ipa', + builderEnvironment: { + image: 'default', + node: '1.2.3', + corepack: true, + yarn: '2.3.4', + fastlane: '3.4.5', + cocoapods: '4.5.6', + env: { + ENV_VAR: '123', + }, + }, + expoBuildUrl: 'https://expo.dev/fake/build/url', + initiatingUserId: randomUUID(), + appId: randomUUID(), + }; + + const { value, error } = Ios.JobSchema.validate(genericJob, joiOptions); + expect(value).toMatchObject(genericJob); + expect(error).toBeFalsy(); + }); + + test('valid resign job', () => { + const genericJob = { + mode: BuildMode.RESIGN, + secrets: { + buildCredentials, + }, + type: Workflow.UNKNOWN, + platform: Platform.IOS, + projectArchive: { + type: ArchiveSourceType.NONE, + }, + resign: { + applicationArchiveSource: { + type: ArchiveSourceType.URL, + url: 'http://localhost:3000/a.ipa', + }, + }, + initiatingUserId: randomUUID(), + appId: randomUUID(), + }; + + const { value, error } = Ios.JobSchema.validate(genericJob, joiOptions); + expect(value).toMatchObject(genericJob); + expect(error).toBeFalsy(); + }); + + test('valid custom build job with metadataLocation', () => { + const customBuildJob = { + mode: BuildMode.CUSTOM, + type: Workflow.UNKNOWN, + platform: Platform.IOS, + projectArchive: { + type: ArchiveSourceType.GCS, + bucketKey: 'path/to/file', + metadataLocation: 'path/to/metadata', + }, + projectRootDirectory: '.', + customBuildConfig: { + path: 'production.ios.yml', + }, + initiatingUserId: randomUUID(), + appId: randomUUID(), + }; + + const { value, error } = Ios.JobSchema.validate(customBuildJob, joiOptions); + expect(value).toMatchObject(customBuildJob); + expect(error).toBeFalsy(); + }); + + test('valid custom build job', () => { + const customBuildJob = { + mode: BuildMode.CUSTOM, + type: Workflow.UNKNOWN, + platform: Platform.IOS, + projectArchive: { + type: ArchiveSourceType.URL, + url: 'https://expo.dev/builds/123', + }, + projectRootDirectory: '.', + customBuildConfig: { + path: 'production.ios.yml', + }, + initiatingUserId: randomUUID(), + appId: randomUUID(), + }; + + const { value, error } = Ios.JobSchema.validate(customBuildJob, joiOptions); + expect(value).toMatchObject(customBuildJob); + expect(error).toBeFalsy(); + }); + + test('valid custom build job with steps', () => { + const customBuildJob = { + mode: BuildMode.CUSTOM, + type: Workflow.UNKNOWN, + platform: Platform.IOS, + projectArchive: { + type: ArchiveSourceType.URL, + url: 'https://expo.dev/builds/123', + }, + projectRootDirectory: '.', + steps: [ + { + id: 'step1', + name: 'Step 1', + run: 'echo Hello, world!', + shell: 'sh', + }, + ], + outputs: {}, + initiatingUserId: randomUUID(), + appId: randomUUID(), + workflowInterpolationContext: { + after: { + setup: { + status: 'success', + outputs: {}, + }, + }, + needs: { + setup: { + status: 'success', + outputs: {}, + }, + }, + github: { + event_name: 'push', + sha: '123', + ref: 'master', + ref_name: 'master', + ref_type: 'branch', + commit_message: 'commit message', + triggering_actor: 'johnny', + }, + workflow: { + id: randomUUID(), + name: 'Build app', + filename: 'build.yml', + url: `https://expo.dev/workflows/${randomUUID()}`, + }, + }, + }; + + const { value, error } = Ios.JobSchema.validate(customBuildJob, joiOptions); + expect(value).toMatchObject(customBuildJob); + expect(error).toBeFalsy(); + }); + + test('invalid generic job', () => { + const genericJob = { + secrets: { + buildCredentials, + }, + type: Workflow.GENERIC, + platform: Platform.IOS, + projectArchive: { + type: ArchiveSourceType.URL, + url: 'url', + }, + projectRootDirectory: '.', + initiatingUserId: randomUUID(), + appId: randomUUID(), + uknownField: 'field', + }; + + const { value, error } = Ios.JobSchema.validate(genericJob, joiOptions); + expect(error?.message).toBe('"projectArchive.url" must be a valid uri'); + expect(value).not.toMatchObject(genericJob); + }); + + test('valid managed job', () => { + const managedJob = { + secrets: { + buildCredentials, + }, + type: Workflow.MANAGED, + platform: Platform.IOS, + projectArchive: { + type: ArchiveSourceType.URL, + url: 'http://localhost:3000', + }, + projectRootDirectory: '.', + username: 'turtle-tutorial', + builderEnvironment: { + image: 'default', + node: '1.2.3', + yarn: '2.3.4', + fastlane: '3.4.5', + cocoapods: '4.5.6', + env: { + ENV_VAR: '123', + }, + }, + initiatingUserId: randomUUID(), + appId: randomUUID(), + }; + + const { value, error } = Ios.JobSchema.validate(managedJob, joiOptions); + expect(value).toMatchObject(managedJob); + expect(error).toBeFalsy(); + }); + + test('valid job with none archive source type', () => { + const managedJob = { + secrets: { + buildCredentials, + }, + type: Workflow.MANAGED, + platform: Platform.IOS, + projectArchive: { + type: ArchiveSourceType.NONE, + }, + projectRootDirectory: '.', + username: 'turtle-tutorial', + builderEnvironment: { + image: 'default', + node: '1.2.3', + yarn: '2.3.4', + fastlane: '3.4.5', + cocoapods: '4.5.6', + env: { + ENV_VAR: '123', + }, + }, + initiatingUserId: randomUUID(), + appId: randomUUID(), + }; + + const { value, error } = Ios.JobSchema.validate(managedJob, joiOptions); + expect(value).toMatchObject(managedJob); + expect(error).toBeFalsy(); + }); + + test('valid job with environment', () => { + const environments = ['production', 'preview', 'development', 'staging', 'custom-env']; + + environments.forEach((env) => { + const jobWithEnvironment = { + secrets: { + buildCredentials, + }, + type: Workflow.GENERIC, + platform: Platform.IOS, + projectArchive: { + type: ArchiveSourceType.URL, + url: 'http://localhost:3000', + }, + projectRootDirectory: '.', + scheme: 'testapp', + buildConfiguration: 'Release', + applicationArchivePath: 'ios/build/*.ipa', + builderEnvironment: { + image: 'default', + node: '1.2.3', + corepack: true, + yarn: '2.3.4', + fastlane: '3.4.5', + cocoapods: '4.5.6', + env: { + ENV_VAR: '123', + }, + }, + expoBuildUrl: 'https://expo.dev/fake/build/url', + initiatingUserId: randomUUID(), + appId: randomUUID(), + environment: env, + }; + + const { value, error } = Ios.JobSchema.validate(jobWithEnvironment, joiOptions); + expect(error).toBeFalsy(); + expect(value.environment).toBe(env); + }); + }); + + test('invalid managed job', () => { + const managedJob = { + secrets: { + buildCredentials, + }, + type: Workflow.MANAGED, + platform: Platform.IOS, + projectArchive: { + type: ArchiveSourceType.URL, + url: 'url', + }, + projectRootDirectory: 312, + initiatingUserId: randomUUID(), + appId: randomUUID(), + uknownField: 'field', + }; + + const { value, error } = Ios.JobSchema.validate(managedJob, joiOptions); + expect(error?.message).toBe( + '"projectArchive.url" must be a valid uri. "projectRootDirectory" must be a string' + ); + expect(value).not.toMatchObject(managedJob); + }); + test('validates channel', () => { + const managedJob = { + secrets: { + buildCredentials, + }, + type: Workflow.MANAGED, + platform: Platform.IOS, + updates: { + channel: 'main', + }, + projectArchive: { + type: ArchiveSourceType.URL, + url: 'http://localhost:3000', + }, + projectRootDirectory: '.', + initiatingUserId: randomUUID(), + appId: randomUUID(), + }; + + const { value, error } = Ios.JobSchema.validate(managedJob, joiOptions); + expect(value).toMatchObject(managedJob); + expect(error).toBeFalsy(); + }); + + test('can set github trigger options', () => { + const job = { + mode: BuildMode.BUILD, + type: Workflow.UNKNOWN, + platform: Platform.IOS, + projectArchive: { + type: ArchiveSourceType.URL, + url: 'https://expo.dev/builds/123', + }, + projectRootDirectory: '.', + githubTriggerOptions: { + autoSubmit: true, + submitProfile: 'default', + }, + secrets: { + buildCredentials, + }, + initiatingUserId: randomUUID(), + appId: randomUUID(), + }; + const { value, error } = Ios.JobSchema.validate(job, joiOptions); + expect(value).toMatchObject(job); + expect(error).toBeFalsy(); + }); + + test('can set loggerLevel', () => { + const job = { + mode: BuildMode.BUILD, + type: Workflow.UNKNOWN, + platform: Platform.IOS, + projectArchive: { + type: ArchiveSourceType.URL, + url: 'https://expo.dev/builds/123', + }, + projectRootDirectory: '.', + secrets: { + buildCredentials, + }, + loggerLevel: LoggerLevel.INFO, + initiatingUserId: randomUUID(), + appId: randomUUID(), + }; + const { value, error } = Ios.JobSchema.validate(job, joiOptions); + expect(value).toMatchObject(job); + expect(error).toBeFalsy(); + }); + + test('can set build mode === repack with steps', () => { + const job = { + mode: BuildMode.REPACK, + type: Workflow.UNKNOWN, + platform: Platform.IOS, + steps: [ + { + id: 'step1', + name: 'Step 1', + run: 'echo Hello, world!', + shell: 'sh', + }, + ], + outputs: {}, + projectArchive: { + type: ArchiveSourceType.URL, + url: 'https://expo.dev/builds/123', + }, + projectRootDirectory: '.', + secrets: { + buildCredentials, + }, + initiatingUserId: randomUUID(), + appId: randomUUID(), + }; + const { value, error } = Ios.JobSchema.validate(job, joiOptions); + expect(value).toMatchObject(job); + expect(error).toBeFalsy(); + }); +}); diff --git a/packages/eas-build-job/src/__tests__/metadata.test.ts b/packages/eas-build-job/src/__tests__/metadata.test.ts new file mode 100644 index 0000000000..6b0d8c2d2a --- /dev/null +++ b/packages/eas-build-job/src/__tests__/metadata.test.ts @@ -0,0 +1,95 @@ +import { Metadata, MetadataSchema } from '../metadata'; + +const validMetadata: Metadata = { + appName: 'testapp', + appVersion: '1.0.0', + appBuildVersion: '123', + runtimeVersion: '3.2.1', + fingerprintHash: '752e99d2b8fde1bf07ebb8af1b4a3c26a6703943', + cliVersion: '1.2.3', + buildProfile: 'release', + credentialsSource: 'remote', + distribution: 'store', + gitCommitHash: '752e99d2b8fde1bf07ebb8af1b4a3c26a6703943', + gitCommitMessage: 'Lorem ipsum', + trackingContext: {}, + workflow: 'generic' as any, + username: 'notdominik', + iosEnterpriseProvisioning: 'adhoc', + message: 'fix foo, bar, and baz', + runFromCI: true, + runWithNoWaitFlag: true, + customWorkflowName: 'blah blah', + developmentClient: true, + requiredPackageManager: 'yarn', + simulator: true, + selectedImage: 'default', + customNodeVersion: '12.0.0', +}; + +describe('MetadataSchema', () => { + test('valid metadata', () => { + const { value, error } = MetadataSchema.validate(validMetadata, { + stripUnknown: true, + convert: true, + abortEarly: false, + }); + expect(error).toBeFalsy(); + expect(value).toEqual(validMetadata); + }); + + test('valid metadata with environment', () => { + const environments = ['production', 'preview', 'development', 'staging', 'custom-env']; + + environments.forEach((env) => { + const metadataWithEnvironment = { + ...validMetadata, + environment: env, + }; + + const { value, error } = MetadataSchema.validate(metadataWithEnvironment, { + stripUnknown: true, + convert: true, + abortEarly: false, + }); + + expect(error).toBeFalsy(); + expect(value.environment).toBe(env); + }); + }); + + test('invalid metadata', () => { + const metadata = { + appName: 'testapp', + appVersion: '1.0.0', + appBuildVersion: '123', + runtimeVersion: '3.2.1', + cliVersion: '1.2.3', + buildProfile: 'release', + credentialsSource: 'blah', + distribution: 'store', + gitCommitHash: 'inv4lid-h@sh', + gitCommitMessage: 'a'.repeat(4097), + trackingContext: {}, + workflow: 'generic', + username: 'notdominik', + message: 'a'.repeat(1025), + runFromCI: true, + runWithNoWaitFlag: true, + customWorkflowName: 'blah blah', + developmentClient: true, + requiredPackageManager: 'yarn', + simulator: false, + selectedImage: 'default', + customNodeVersion: '12.0.0', + }; + const { error } = MetadataSchema.validate(metadata, { + stripUnknown: true, + convert: true, + abortEarly: false, + }); + expect(error?.message).toEqual( + '"credentialsSource" must be one of [local, remote]. "gitCommitHash" length must be 40 characters long. "gitCommitHash" must only contain hexadecimal characters. "gitCommitMessage" length must be less than or equal to 4096 characters long. "message" length must be less than or equal to 1024 characters long' + ); + }); +}); diff --git a/packages/eas-build-job/src/__tests__/step.test.ts b/packages/eas-build-job/src/__tests__/step.test.ts new file mode 100644 index 0000000000..26b1589190 --- /dev/null +++ b/packages/eas-build-job/src/__tests__/step.test.ts @@ -0,0 +1,111 @@ +import { isStepFunctionStep, isStepShellStep, StepZ, validateSteps } from '../step'; + +describe('StepZ', () => { + it('accepts valid script step', () => { + const step = { + run: 'echo Hello, world!', + shell: 'sh', + outputs: [ + { + name: 'my_output', + required: true, + }, + { + name: 'my_optional_output', + required: false, + }, + { + name: 'my_optional_output_without_required', + }, + ], + }; + expect(StepZ.parse(step)).toEqual(step); + }); + + it('accepts valid function step', () => { + const step = { + uses: 'eas/build', + with: { + arg1: 'value1', + arg2: 2, + arg3: { + key1: 'value1', + key2: ['value1'], + }, + arg4: '${{ steps.step1.outputs.test }}', + arg5: true, + arg6: [1, 2, 3], + }, + }; + expect(StepZ.parse(step)).toEqual(step); + }); + + it('errors when step is both script and function step', () => { + const step = { + run: 'echo Hello, world!', + uses: 'eas/build', + }; + expect(() => StepZ.parse(step)).toThrow('Invalid input'); + }); + + it('errors when step is neither script nor function step', () => { + const step = { + id: 'step1', + name: 'Step 1', + }; + expect(() => StepZ.parse(step)).toThrow('Invalid input'); + }); + + it('valid step with all properties', () => { + const step = { + id: 'step1', + name: 'Step 1', + if: '${ steps.step1.outputs.test } == 1', + run: 'echo Hello, world!', + shell: 'sh', + env: { + KEY1: 'value1', + }, + }; + expect(StepZ.parse(step)).toEqual(step); + }); +}); + +describe(isStepShellStep, () => { + it('returns true for shell step', () => { + expect(isStepShellStep({ run: 'echo Hello, world!', shell: 'sh' })).toBe(true); + }); + + it('returns false for function step', () => { + expect(isStepShellStep({ uses: 'eas/build' })).toBe(false); + }); +}); + +describe(isStepFunctionStep, () => { + it('returns true for function step', () => { + expect(isStepFunctionStep({ uses: 'eas/build' })).toBe(true); + }); + + it('returns false for shell step', () => { + expect(isStepFunctionStep({ run: 'echo Hello, world!', shell: 'sh' })).toBe(false); + }); +}); + +describe(validateSteps, () => { + it('accepts valid steps', () => { + const steps = [ + { + run: 'echo Hello, world!', + shell: 'sh', + }, + { + uses: 'eas/build', + }, + ]; + expect(validateSteps(steps)).toEqual(steps); + }); + + it('errors when steps is empty', () => { + expect(() => validateSteps([])).toThrow('Too small: expected array to have >=1 items'); + }); +}); diff --git a/packages/eas-build-job/src/android.ts b/packages/eas-build-job/src/android.ts new file mode 100644 index 0000000000..7c4ced7c59 --- /dev/null +++ b/packages/eas-build-job/src/android.ts @@ -0,0 +1,194 @@ +import Joi from 'joi'; +import { LoggerLevel } from '@expo/logger'; + +import { + ArchiveSource, + ArchiveSourceSchema, + Env, + EnvSchema, + Platform, + Workflow, + Cache, + CacheSchema, + EnvironmentSecretsSchema, + EnvironmentSecret, + BuildTrigger, + BuildMode, + StaticWorkflowInterpolationContextZ, + StaticWorkflowInterpolationContext, + CustomBuildConfigSchema, +} from './common'; +import { Step } from './step'; + +export interface Keystore { + dataBase64: string; + keystorePassword: string; + keyAlias: string; + keyPassword?: string; +} + +const KeystoreSchema = Joi.object({ + dataBase64: Joi.string().required(), + keystorePassword: Joi.string().allow('').required(), + keyAlias: Joi.string().required(), + keyPassword: Joi.string().allow(''), +}); + +export enum BuildType { + APK = 'apk', + APP_BUNDLE = 'app-bundle', +} + +export interface BuilderEnvironment { + image?: string; + node?: string; + corepack?: boolean; + pnpm?: string; + yarn?: string; + bun?: string; + ndk?: string; + env?: Env; +} + +const BuilderEnvironmentSchema = Joi.object({ + image: Joi.string(), + node: Joi.string(), + corepack: Joi.boolean(), + yarn: Joi.string(), + pnpm: Joi.string(), + bun: Joi.string(), + ndk: Joi.string(), + env: EnvSchema, +}); + +export interface BuildSecrets { + buildCredentials?: { + keystore: Keystore; + }; + environmentSecrets?: EnvironmentSecret[]; + robotAccessToken?: string; +} + +export interface Job { + mode: BuildMode; + type: Workflow; + triggeredBy: BuildTrigger; + projectArchive: ArchiveSource; + platform: Platform.ANDROID; + projectRootDirectory: string; + buildProfile?: string; + updates?: { + channel?: string; + }; + secrets?: BuildSecrets; + builderEnvironment?: BuilderEnvironment; + cache?: Cache; + developmentClient?: boolean; + version?: { + versionCode?: string; + /** + * support for this field is implemented, but specifying it is disabled on schema level + */ + versionName?: string; + /** + * support for this field is implemented, but specifying it is disabled on schema level + */ + runtimeVersion?: string; + }; + buildArtifactPaths?: string[]; + + gradleCommand?: string; + applicationArchivePath?: string; + + buildType?: BuildType; + username?: string; + + customBuildConfig?: { + path: string; + }; + steps?: Step[]; + outputs?: Record; + + experimental?: { + prebuildCommand?: string; + }; + expoBuildUrl?: string; + githubTriggerOptions?: { + autoSubmit: boolean; + submitProfile?: string; + }; + loggerLevel?: LoggerLevel; + + workflowInterpolationContext?: StaticWorkflowInterpolationContext; + + initiatingUserId: string; + appId: string; + + environment?: string; +} + +const SecretsSchema = Joi.object({ + buildCredentials: Joi.object({ keystore: KeystoreSchema.required() }), + environmentSecrets: EnvironmentSecretsSchema, + robotAccessToken: Joi.string(), +}); + +export const JobSchema = Joi.object({ + mode: Joi.string() + .valid(BuildMode.BUILD, BuildMode.CUSTOM, BuildMode.REPACK) + .default(BuildMode.BUILD), + type: Joi.string() + .valid(...Object.values(Workflow)) + .required(), + triggeredBy: Joi.string() + .valid(...Object.values(BuildTrigger)) + .default(BuildTrigger.EAS_CLI), + projectArchive: ArchiveSourceSchema.required(), + platform: Joi.string().valid(Platform.ANDROID).required(), + projectRootDirectory: Joi.string().required(), + buildProfile: Joi.when('triggeredBy', { + is: BuildTrigger.GIT_BASED_INTEGRATION, + then: Joi.string().required(), + otherwise: Joi.string(), + }), + updates: Joi.object({ + channel: Joi.string(), + }), + secrets: Joi.when('mode', { + is: Joi.string().valid(BuildMode.CUSTOM), + then: SecretsSchema, + otherwise: SecretsSchema.required(), + }), + builderEnvironment: BuilderEnvironmentSchema, + cache: CacheSchema.default(), + developmentClient: Joi.boolean(), + version: Joi.object({ + versionCode: Joi.string().regex(/^\d+$/), + }), + buildArtifactPaths: Joi.array().items(Joi.string()), + + gradleCommand: Joi.string(), + applicationArchivePath: Joi.string(), + + buildType: Joi.string().valid(...Object.values(BuildType)), + username: Joi.string(), + + experimental: Joi.object({ + prebuildCommand: Joi.string(), + }), + expoBuildUrl: Joi.string().uri().optional(), + githubTriggerOptions: Joi.object({ + autoSubmit: Joi.boolean().default(false), + submitProfile: Joi.string(), + }), + loggerLevel: Joi.string().valid(...Object.values(LoggerLevel)), + + initiatingUserId: Joi.string().required(), + appId: Joi.string().required(), + + environment: Joi.string(), + + workflowInterpolationContext: Joi.object().custom((workflowInterpolationContext) => + StaticWorkflowInterpolationContextZ.optional().parse(workflowInterpolationContext) + ), +}).concat(CustomBuildConfigSchema); diff --git a/packages/eas-build-job/src/artifacts.ts b/packages/eas-build-job/src/artifacts.ts new file mode 100644 index 0000000000..643176e3fd --- /dev/null +++ b/packages/eas-build-job/src/artifacts.ts @@ -0,0 +1,29 @@ +export enum ManagedArtifactType { + APPLICATION_ARCHIVE = 'APPLICATION_ARCHIVE', + BUILD_ARTIFACTS = 'BUILD_ARTIFACTS', + /** + * @deprecated + */ + XCODE_BUILD_LOGS = 'XCODE_BUILD_LOGS', +} + +export enum GenericArtifactType { + ANDROID_APK = 'android-apk', + ANDROID_AAB = 'android-aab', + + IOS_SIMULATOR_APP = 'ios-simulator-app', + IOS_IPA = 'ios-ipa', + + OTHER = 'other', +} + +export const isGenericArtifact = < + TSpec extends { type: GenericArtifactType | ManagedArtifactType }, +>( + artifactSpec: TSpec +): artifactSpec is TSpec & { type: GenericArtifactType } => { + if (Object.values(GenericArtifactType).includes(artifactSpec.type as GenericArtifactType)) { + return true; + } + return false; +}; diff --git a/packages/eas-build-job/src/common.ts b/packages/eas-build-job/src/common.ts new file mode 100644 index 0000000000..d8ef60c017 --- /dev/null +++ b/packages/eas-build-job/src/common.ts @@ -0,0 +1,293 @@ +import Joi from 'joi'; +import { z } from 'zod'; + +import { BuildPhase, BuildPhaseResult } from './logs'; +import { validateSteps } from './step'; + +export enum BuildMode { + BUILD = 'build', + RESIGN = 'resign', + CUSTOM = 'custom', + REPACK = 'repack', +} + +export enum Workflow { + GENERIC = 'generic', + MANAGED = 'managed', + UNKNOWN = 'unknown', +} + +export enum Platform { + ANDROID = 'android', + IOS = 'ios', +} + +export enum ArchiveSourceType { + NONE = 'NONE', + URL = 'URL', + PATH = 'PATH', + GCS = 'GCS', + GIT = 'GIT', + R2 = 'R2', +} + +export enum BuildTrigger { + EAS_CLI = 'EAS_CLI', + GIT_BASED_INTEGRATION = 'GIT_BASED_INTEGRATION', +} + +export type ArchiveSource = + | { type: ArchiveSourceType.NONE } + | { type: ArchiveSourceType.GCS; bucketKey: string; metadataLocation?: string } + | { type: ArchiveSourceType.R2; bucketKey: string } + | { type: ArchiveSourceType.URL; url: string } + | { type: ArchiveSourceType.PATH; path: string } + | { + type: ArchiveSourceType.GIT; + /** + * Url that can be used to clone repository. + * It should contain embedded credentials for private registries. + */ + repositoryUrl: string; + /** A Git ref - points to a branch head, tag head or a branch name. */ + gitRef: string | null; + /** + * Git commit hash. + */ + gitCommitHash: string; + }; + +export const ArchiveSourceSchema = Joi.object({ + type: Joi.string() + .valid(...Object.values(ArchiveSourceType)) + .required(), +}) + .when(Joi.object({ type: ArchiveSourceType.GCS }).unknown(), { + then: Joi.object({ + type: Joi.string().valid(ArchiveSourceType.GCS).required(), + bucketKey: Joi.string().required(), + metadataLocation: Joi.string(), + }), + }) + .when(Joi.object({ type: ArchiveSourceType.URL }).unknown(), { + then: Joi.object({ + type: Joi.string().valid(ArchiveSourceType.URL).required(), + url: Joi.string().uri().required(), + }), + }) + .when(Joi.object({ type: ArchiveSourceType.GIT }).unknown(), { + then: Joi.object({ + type: Joi.string().valid(ArchiveSourceType.GIT).required(), + repositoryUrl: Joi.string().required(), + gitCommitHash: Joi.string().required(), + gitRef: Joi.string().allow(null).required(), + }), + }) + .when(Joi.object({ type: ArchiveSourceType.PATH }).unknown(), { + then: Joi.object({ + type: Joi.string().valid(ArchiveSourceType.PATH).required(), + path: Joi.string().required(), + }), + }); + +export const ArchiveSourceSchemaZ = z.discriminatedUnion('type', [ + z.object({ + type: z.literal(ArchiveSourceType.GIT), + repositoryUrl: z.string().url(), + gitRef: z.string().nullable(), + gitCommitHash: z.string(), + }), + z.object({ + type: z.literal(ArchiveSourceType.PATH), + path: z.string(), + }), + z.object({ + type: z.literal(ArchiveSourceType.URL), + url: z.string().url(), + }), + z.object({ + type: z.literal(ArchiveSourceType.GCS), + bucketKey: z.string(), + metadataLocation: z.string().optional(), + }), + z.object({ + type: z.literal(ArchiveSourceType.NONE), + }), +]); + +export type Env = Record; +export const EnvSchema = Joi.object().pattern(Joi.string(), Joi.string()); + +export type EnvironmentSecret = { + name: string; + type: EnvironmentSecretType; + value: string; +}; +export enum EnvironmentSecretType { + STRING = 'string', + FILE = 'file', +} +export const EnvironmentSecretsSchema = Joi.array().items( + Joi.object({ + name: Joi.string().required(), + value: Joi.string().allow('').required(), + type: Joi.string() + .valid(...Object.values(EnvironmentSecretType)) + .required(), + }) +); +export const EnvironmentSecretZ = z.object({ + name: z.string(), + value: z.string(), + type: z.nativeEnum(EnvironmentSecretType), +}); + +export interface Cache { + disabled: boolean; + clear: boolean; + key?: string; + /** + * @deprecated We don't cache anything by default anymore. + */ + cacheDefaultPaths?: boolean; + /** + * @deprecated We use paths now since there is no default caching anymore. + */ + customPaths?: string[]; + paths: string[]; +} + +export const CacheSchema = Joi.object({ + disabled: Joi.boolean().default(false), + clear: Joi.boolean().default(false), + key: Joi.string().allow('').max(128), + cacheDefaultPaths: Joi.boolean(), + customPaths: Joi.array().items(Joi.string()), + paths: Joi.array().items(Joi.string()).default([]), +}); + +export interface BuildPhaseStats { + buildPhase: BuildPhase; + result: BuildPhaseResult; + durationMs: number; +} + +const GitHubContextZ = z.object({ + triggering_actor: z.string().optional(), + event_name: z.enum(['push', 'pull_request', 'workflow_dispatch', 'schedule']), + sha: z.string(), + ref: z.string(), + ref_name: z.string(), + ref_type: z.string(), + commit_message: z.string().optional(), + label: z.string().optional(), + repository: z.string().optional(), + repository_owner: z.string().optional(), + event: z + .object({ + label: z + .object({ + name: z.string(), + }) + .optional(), + head_commit: z + .object({ + message: z.string(), + id: z.string(), + }) + .optional(), + pull_request: z + .object({ + number: z.number(), + }) + .optional(), + number: z.number().optional(), + schedule: z.string().optional(), + inputs: z.record(z.string(), z.union([z.string(), z.number(), z.boolean()])).optional(), + }) + .optional(), +}); + +export const StaticWorkflowInterpolationContextZ = z.object({ + after: z.record( + z.string(), + z.object({ + status: z.string(), + outputs: z.record(z.string(), z.string().nullable()), + }) + ), + needs: z.record( + z.string(), + z.object({ + status: z.string(), + outputs: z.record(z.string(), z.string().nullable()), + }) + ), + inputs: z.record(z.string(), z.union([z.string(), z.number(), z.boolean()])).optional(), + github: + // We need to .optional() to support jobs that are not triggered by a GitHub event. + GitHubContextZ.optional(), + workflow: z + .object({ + id: z.string(), + name: z.string(), + filename: z.string(), + url: z.string().url(), + }) + .passthrough(), +}); + +export type StaticWorkflowInterpolationContext = z.infer< + typeof StaticWorkflowInterpolationContextZ +>; + +export type DynamicInterpolationContext = { + env: Record; + success: () => boolean; + failure: () => boolean; + always: () => boolean; + never: () => boolean; + fromJSON: (value: string) => unknown; + toJSON: (value: unknown) => string; + contains: (value: string, substring: string) => boolean; + startsWith: (value: string, prefix: string) => boolean; + endsWith: (value: string, suffix: string) => boolean; + hashFiles: (...globs: string[]) => string; + replaceAll: (input: string, stringToReplace: string, replacementString: string) => string; + substring: (input: string, start: number, end?: number) => string; +}; + +export type WorkflowInterpolationContext = StaticWorkflowInterpolationContext & + DynamicInterpolationContext; + +export const CustomBuildConfigSchema = Joi.object().when('.mode', { + is: [BuildMode.CUSTOM, BuildMode.REPACK], + then: Joi.object().when('.customBuildConfig.path', { + is: Joi.exist(), + then: Joi.object({ + customBuildConfig: Joi.object({ + path: Joi.string().required(), + }).required(), + steps: Joi.any().strip(), + outputs: Joi.any().strip(), + }), + otherwise: Joi.object({ + customBuildConfig: Joi.any().strip(), + steps: Joi.array() + .items(Joi.any()) + .required() + .custom((steps) => validateSteps(steps), 'steps validation'), + outputs: Joi.object().pattern(Joi.string(), Joi.string()).required(), + }), + }), + otherwise: Joi.object({ + customBuildConfig: Joi.any().strip(), + steps: Joi.any().strip(), + outputs: Joi.any().strip(), + }), +}); + +export enum EasCliNpmTags { + STAGING = 'latest-eas-build-staging', + PRODUCTION = 'latest-eas-build', +} diff --git a/packages/eas-build-job/src/context.ts b/packages/eas-build-job/src/context.ts new file mode 100644 index 0000000000..b114f50639 --- /dev/null +++ b/packages/eas-build-job/src/context.ts @@ -0,0 +1,21 @@ +import { DynamicInterpolationContext, StaticWorkflowInterpolationContext } from './common'; +import { Job } from './job'; +import { Metadata } from './metadata'; + +type StaticJobOnlyInterpolationContext = { + job: Job; + metadata: Metadata | null; + steps: Record< + string, + { + outputs: Record; + } + >; + expoApiServerURL: string; +}; + +export type StaticJobInterpolationContext = + | (StaticWorkflowInterpolationContext & StaticJobOnlyInterpolationContext) + | StaticJobOnlyInterpolationContext; + +export type JobInterpolationContext = StaticJobInterpolationContext & DynamicInterpolationContext; diff --git a/packages/eas-build-job/src/errors.ts b/packages/eas-build-job/src/errors.ts new file mode 100644 index 0000000000..3156bdef56 --- /dev/null +++ b/packages/eas-build-job/src/errors.ts @@ -0,0 +1,105 @@ +import { BuildPhase, buildPhaseDisplayName } from './logs'; + +export enum ErrorCode { + UNKNOWN_ERROR = 'UNKNOWN_ERROR', + UNKNOWN_CUSTOM_BUILD_ERROR = 'UNKNOWN_CUSTOM_BUILD_ERROR', + SERVER_ERROR = 'SERVER_ERROR', + UNKNOWN_FASTLANE_ERROR = 'EAS_BUILD_UNKNOWN_FASTLANE_ERROR', + UNKNOWN_FASTLANE_RESIGN_ERROR = 'EAS_BUILD_UNKNOWN_FASTLANE_RESIGN_ERROR', + UNKNOWN_GRADLE_ERROR = 'EAS_BUILD_UNKNOWN_GRADLE_ERROR', + BUILD_TIMEOUT_ERROR = 'EAS_BUILD_TIMEOUT_ERROR', +} + +export interface ExternalBuildError { + errorCode: string; + message: string; + docsUrl?: string; + buildPhase?: BuildPhase; +} + +interface BuildErrorDetails { + errorCode: string; + userFacingMessage: string; + userFacingErrorCode: string; + docsUrl?: string; + innerError?: Error; + buildPhase?: BuildPhase; +} + +export class BuildError extends Error { + public errorCode: string; + public userFacingMessage: string; + public userFacingErrorCode: string; + public docsUrl?: string; + public innerError?: Error; + public buildPhase?: BuildPhase; + + constructor(message: string, details: BuildErrorDetails) { + super(message); + this.errorCode = details.errorCode; + this.userFacingErrorCode = details.userFacingErrorCode; + this.userFacingMessage = details.userFacingMessage; + this.docsUrl = details.docsUrl; + this.innerError = details.innerError; + this.buildPhase = details.buildPhase; + } + public format(): ExternalBuildError { + return { + errorCode: this.userFacingErrorCode, + message: this.userFacingMessage, + docsUrl: this.docsUrl, + buildPhase: this.buildPhase, + }; + } +} + +export class UserFacingError extends Error { + constructor( + public errorCode: string, + public message: string, + public docsUrl?: string + ) { + super(message); + } +} + +export class UnknownError extends UserFacingError { + constructor(buildPhase?: BuildPhase) { + super( + ErrorCode.UNKNOWN_ERROR, + buildPhase + ? `Unknown error. See logs of the ${buildPhaseDisplayName[buildPhase]} build phase for more information.` + : 'Unknown error. See logs for more information.' + ); + } +} + +export class UnknownBuildError extends BuildError { + constructor() { + const errorCode = ErrorCode.UNKNOWN_ERROR; + const message = 'Unknown error. See logs for more information.'; + super(message, { + errorCode, + userFacingMessage: message, + userFacingErrorCode: errorCode, + }); + } +} + +export class UnknownCustomBuildError extends BuildError { + constructor() { + const errorCode = ErrorCode.UNKNOWN_CUSTOM_BUILD_ERROR; + const message = 'Unknown custom build error. See logs for more information.'; + super(message, { + errorCode, + userFacingMessage: message, + userFacingErrorCode: errorCode, + }); + } +} + +export class CredentialsDistCertMismatchError extends UserFacingError { + constructor(message: string) { + super('EAS_BUILD_CREDENTIALS_DIST_CERT_MISMATCH', message); + } +} diff --git a/packages/eas-build-job/src/generic.ts b/packages/eas-build-job/src/generic.ts new file mode 100644 index 0000000000..0844e0ab75 --- /dev/null +++ b/packages/eas-build-job/src/generic.ts @@ -0,0 +1,54 @@ +import { z } from 'zod'; +import { LoggerLevel } from '@expo/logger'; + +import { + ArchiveSourceSchemaZ, + BuildTrigger, + EnvironmentSecretZ, + StaticWorkflowInterpolationContextZ, +} from './common'; +import { StepZ } from './step'; + +export namespace Generic { + const BuilderEnvironmentSchemaZ = z.object({ + image: z.string(), + node: z.string().optional(), + corepack: z.boolean().optional(), + yarn: z.string().optional(), + pnpm: z.string().optional(), + bun: z.string().optional(), + env: z.record(z.string(), z.string()), + // Linux + ndk: z.string().optional(), + // macOS + bundler: z.string().optional(), + fastlane: z.string().optional(), + cocoapods: z.string().optional(), + }); + + export type Job = z.infer; + export const JobZ = z.object({ + projectArchive: ArchiveSourceSchemaZ, + secrets: z.object({ + robotAccessToken: z.string(), + environmentSecrets: z.array(EnvironmentSecretZ), + }), + expoDevUrl: z.string().url(), + builderEnvironment: BuilderEnvironmentSchemaZ, + // We use this to discern between Android.Job, Ios.Job and Generic.Job. + platform: z.never().optional(), + type: z.never().optional(), + triggeredBy: z.literal(BuildTrigger.GIT_BASED_INTEGRATION), + loggerLevel: z.nativeEnum(LoggerLevel).optional(), + workflowInterpolationContext: StaticWorkflowInterpolationContextZ.optional(), + + initiatingUserId: z.string(), + appId: z.string(), + + steps: z.array(StepZ).min(1), + outputs: z.record(z.string(), z.string()).optional(), + }); + + export type PartialJob = z.infer; + export const PartialJobZ = JobZ.partial(); +} diff --git a/packages/eas-build-job/src/index.ts b/packages/eas-build-job/src/index.ts new file mode 100644 index 0000000000..1cd45645a0 --- /dev/null +++ b/packages/eas-build-job/src/index.ts @@ -0,0 +1,30 @@ +export * as Android from './android'; +export * as Ios from './ios'; +export { + ArchiveSourceType, + ArchiveSource, + ArchiveSourceSchemaZ, + BuildMode, + BuildPhaseStats, + BuildTrigger, + EasCliNpmTags, + Env, + EnvironmentSecret, + EnvironmentSecretType, + Workflow, + Platform, + Cache, + WorkflowInterpolationContext, +} from './common'; +export { Metadata, sanitizeMetadata } from './metadata'; +export * from './job'; +export * from './logs'; +export * as errors from './errors'; +export * from './artifacts'; +export * from './context'; +export * from './generic'; +export * from './step'; +export * from './submission-config'; + +const version = require('../package.json').version; +export { version }; diff --git a/packages/eas-build-job/src/ios.ts b/packages/eas-build-job/src/ios.ts new file mode 100644 index 0000000000..7cce3e0c97 --- /dev/null +++ b/packages/eas-build-job/src/ios.ts @@ -0,0 +1,228 @@ +import Joi from 'joi'; +import { LoggerLevel } from '@expo/logger'; + +import { + ArchiveSource, + ArchiveSourceSchema, + Env, + EnvSchema, + Platform, + Workflow, + Cache, + CacheSchema, + EnvironmentSecretsSchema, + EnvironmentSecret, + BuildTrigger, + BuildMode, + StaticWorkflowInterpolationContextZ, + StaticWorkflowInterpolationContext, + CustomBuildConfigSchema, +} from './common'; +import { Step } from './step'; + +export type DistributionType = 'store' | 'internal' | 'simulator'; + +const TargetCredentialsSchema = Joi.object().keys({ + provisioningProfileBase64: Joi.string().required(), + distributionCertificate: Joi.object({ + dataBase64: Joi.string().required(), + password: Joi.string().allow('').required(), + }).required(), +}); + +export interface TargetCredentials { + provisioningProfileBase64: string; + distributionCertificate: DistributionCertificate; +} + +const BuildCredentialsSchema = Joi.object().pattern( + Joi.string().required(), + TargetCredentialsSchema +); + +type Target = string; +export type BuildCredentials = Record; + +export interface DistributionCertificate { + dataBase64: string; + password: string; +} +export interface BuilderEnvironment { + image?: string; + node?: string; + corepack?: boolean; + yarn?: string; + bun?: string; + pnpm?: string; + bundler?: string; + fastlane?: string; + cocoapods?: string; + env?: Env; +} + +const BuilderEnvironmentSchema = Joi.object({ + image: Joi.string(), + node: Joi.string(), + corepack: Joi.boolean(), + yarn: Joi.string(), + pnpm: Joi.string(), + bun: Joi.string(), + bundler: Joi.string(), + fastlane: Joi.string(), + cocoapods: Joi.string(), + env: EnvSchema, +}); + +export interface BuildSecrets { + buildCredentials?: BuildCredentials; + environmentSecrets?: EnvironmentSecret[]; + robotAccessToken?: string; +} + +export interface Job { + mode: BuildMode; + type: Workflow; + triggeredBy: BuildTrigger; + projectArchive: ArchiveSource; + resign?: { + applicationArchiveSource: ArchiveSource; + }; + platform: Platform.IOS; + projectRootDirectory?: string; + buildProfile?: string; + updates?: { + channel?: string; + }; + secrets?: BuildSecrets; + builderEnvironment?: BuilderEnvironment; + cache?: Cache; + developmentClient?: boolean; + simulator?: boolean; + version?: { + buildNumber?: string; + /** + * support for this field is implemented, but specifying it is disabled on schema level + */ + appVersion?: string; + /** + * support for this field is implemented, but specifying it is disabled on schema level + */ + runtimeVersion?: string; + }; + buildArtifactPaths?: string[]; + + scheme?: string; + buildConfiguration?: string; + applicationArchivePath?: string; + + username?: string; + + customBuildConfig?: { + path: string; + }; + steps?: Step[]; + outputs?: Record; + + experimental?: { + prebuildCommand?: string; + }; + expoBuildUrl?: string; + githubTriggerOptions?: { + autoSubmit: boolean; + submitProfile?: string; + }; + loggerLevel?: LoggerLevel; + + workflowInterpolationContext?: StaticWorkflowInterpolationContext; + + initiatingUserId: string; + appId: string; + + environment?: string; +} + +const SecretsSchema = Joi.object({ + buildCredentials: BuildCredentialsSchema, + environmentSecrets: EnvironmentSecretsSchema, + robotAccessToken: Joi.string(), +}); + +export const JobSchema = Joi.object({ + mode: Joi.string() + .valid(...Object.values(BuildMode)) + .default(BuildMode.BUILD), + type: Joi.when('mode', { + is: Joi.string().valid(BuildMode.RESIGN), + then: Joi.string().valid(Workflow.UNKNOWN).default(Workflow.UNKNOWN), + otherwise: Joi.string() + .valid(...Object.values(Workflow)) + .required(), + }), + triggeredBy: Joi.string() + .valid(...Object.values(BuildTrigger)) + .default(BuildTrigger.EAS_CLI), + projectArchive: ArchiveSourceSchema.required(), + resign: Joi.when('mode', { + is: Joi.string().valid(BuildMode.RESIGN), + then: Joi.object({ + applicationArchiveSource: ArchiveSourceSchema.required(), + }).required(), + otherwise: Joi.any().strip(), + }), + platform: Joi.string().valid(Platform.IOS).required(), + projectRootDirectory: Joi.when('mode', { + is: Joi.string().valid(BuildMode.RESIGN), + then: Joi.any().strip(), + otherwise: Joi.string().required(), + }), + buildProfile: Joi.when('mode', { + is: Joi.string().valid(BuildMode.BUILD), + then: Joi.when('triggeredBy', { + is: Joi.string().valid(BuildTrigger.GIT_BASED_INTEGRATION), + then: Joi.string().required(), + otherwise: Joi.string(), + }), + otherwise: Joi.string(), + }), + updates: Joi.object({ + channel: Joi.string(), + }), + secrets: Joi.when('mode', { + is: Joi.string().valid(BuildMode.CUSTOM), + then: SecretsSchema, + otherwise: SecretsSchema.required(), + }), + builderEnvironment: BuilderEnvironmentSchema, + cache: CacheSchema.default(), + developmentClient: Joi.boolean(), + simulator: Joi.boolean(), + version: Joi.object({ + buildNumber: Joi.string(), + }), + buildArtifactPaths: Joi.array().items(Joi.string()), + + scheme: Joi.string(), + buildConfiguration: Joi.string(), + applicationArchivePath: Joi.string(), + + username: Joi.string(), + + experimental: Joi.object({ + prebuildCommand: Joi.string(), + }), + expoBuildUrl: Joi.string().uri().optional(), + githubTriggerOptions: Joi.object({ + autoSubmit: Joi.boolean().default(false), + submitProfile: Joi.string(), + }), + loggerLevel: Joi.string().valid(...Object.values(LoggerLevel)), + + initiatingUserId: Joi.string().required(), + appId: Joi.string().required(), + + environment: Joi.string(), + + workflowInterpolationContext: Joi.object().custom((workflowInterpolationContext) => + StaticWorkflowInterpolationContextZ.optional().parse(workflowInterpolationContext) + ), +}).concat(CustomBuildConfigSchema); diff --git a/packages/eas-build-job/src/job.ts b/packages/eas-build-job/src/job.ts new file mode 100644 index 0000000000..3a9738343b --- /dev/null +++ b/packages/eas-build-job/src/job.ts @@ -0,0 +1,32 @@ +import Joi from 'joi'; + +import { Platform } from './common'; +import * as Android from './android'; +import { Generic } from './generic'; +import * as Ios from './ios'; + +export type BuildJob = Android.Job | Ios.Job; +export type Job = BuildJob | Generic.Job; + +export const JobSchema = Joi.object({ + platform: Joi.string() + .valid(...Object.values(Platform)) + .required(), +}) + .when(Joi.object({ platform: Platform.ANDROID }).unknown(), { then: Android.JobSchema }) + .when(Joi.object({ platform: Platform.IOS }).unknown(), { then: Ios.JobSchema }); + +export function sanitizeBuildJob(rawJob: object): BuildJob { + const { value, error } = JobSchema.validate(rawJob, { + stripUnknown: true, + convert: true, + abortEarly: false, + }); + + if (error) { + throw error; + } else { + const job: BuildJob = value; + return job; + } +} diff --git a/packages/eas-build-job/src/logs.ts b/packages/eas-build-job/src/logs.ts new file mode 100644 index 0000000000..6a944b0624 --- /dev/null +++ b/packages/eas-build-job/src/logs.ts @@ -0,0 +1,205 @@ +export enum BuildPhase { + UNKNOWN = 'UNKNOWN', + QUEUE = 'QUEUE', + SPIN_UP_BUILDER = 'SPIN_UP_BUILDER', + BUILDER_INFO = 'BUILDER_INFO', + READ_APP_CONFIG = 'READ_APP_CONFIG', + READ_PACKAGE_JSON = 'READ_PACKAGE_JSON', + RUN_EXPO_DOCTOR = 'RUN_EXPO_DOCTOR', + SET_UP_BUILD_ENVIRONMENT = 'SET_UP_BUILD_ENVIRONMENT', + START_BUILD = 'START_BUILD', + INSTALL_CUSTOM_TOOLS = 'INSTALL_CUSTOM_TOOLS', + PREPARE_PROJECT = 'PREPARE_PROJECT', + RESTORE_CACHE = 'RESTORE_CACHE', + INSTALL_DEPENDENCIES = 'INSTALL_DEPENDENCIES', + EAS_BUILD_INTERNAL = 'EAS_BUILD_INTERNAL', + PREBUILD = 'PREBUILD', + PREPARE_CREDENTIALS = 'PREPARE_CREDENTIALS', + CALCULATE_EXPO_UPDATES_RUNTIME_VERSION = 'CALCULATE_EXPO_UPDATES_RUNTIME_VERSION', + CONFIGURE_EXPO_UPDATES = 'CONFIGURE_EXPO_UPDATES', + EAGER_BUNDLE = 'EAGER_BUNDLE', + SAVE_CACHE = 'SAVE_CACHE', + CACHE_STATS = 'CACHE_STATS', + /** + * @deprecated + */ + UPLOAD_ARTIFACTS = 'UPLOAD_ARTIFACTS', + UPLOAD_APPLICATION_ARCHIVE = 'UPLOAD_APPLICATION_ARCHIVE', + UPLOAD_BUILD_ARTIFACTS = 'UPLOAD_BUILD_ARTIFACTS', + PREPARE_ARTIFACTS = 'PREPARE_ARTIFACTS', + CLEAN_UP_CREDENTIALS = 'CLEAN_UP_CREDENTIALS', + COMPLETE_BUILD = 'COMPLETE_BUILD', + FAIL_BUILD = 'FAIL_BUILD', + + // ANDROID + FIX_GRADLEW = 'FIX_GRADLEW', + RUN_GRADLEW = 'RUN_GRADLEW', + + // IOS + INSTALL_PODS = 'INSTALL_PODS', + CONFIGURE_XCODE_PROJECT = 'CONFIGURE_XCODE_PROJECT', + RUN_FASTLANE = 'RUN_FASTLANE', + + // HOOKS + PRE_INSTALL_HOOK = 'PRE_INSTALL_HOOK', + POST_INSTALL_HOOK = 'POST_INSTALL_HOOK', + PRE_UPLOAD_ARTIFACTS_HOOK = 'PRE_UPLOAD_ARTIFACTS_HOOK', + ON_BUILD_SUCCESS_HOOK = 'ON_BUILD_SUCCESS_HOOK', + ON_BUILD_ERROR_HOOK = 'ON_BUILD_ERROR_HOOK', + ON_BUILD_COMPLETE_HOOK = 'ON_BUILD_COMPLETE_HOOK', + ON_BUILD_CANCEL_HOOK = 'ON_BUILD_CANCEL_HOOK', + + // RESIGN + DOWNLOAD_APPLICATION_ARCHIVE = 'DOWNLOAD_APPLICATION_ARCHIVE', + + // CUSTOM BUILDS + PARSE_CUSTOM_WORKFLOW_CONFIG = 'PARSE_CUSTOM_WORKFLOW_CONFIG', + CUSTOM = 'CUSTOM', + COMPLETE_JOB = 'COMPLETE_JOB', +} + +export enum SubmissionPhase { + SPIN_UP_SUBMISSION_WORKER = 'SPIN_UP_SUBMISSION_WORKER', + SUBMIT_TO_PLAY_STORE = 'SUBMIT_TO_PLAY_STORE', + SUBMIT_TO_APP_STORE = 'SUBMIT_TO_APP_STORE', + FAIL_SUBMISSION = 'FAIL_SUBMISSION', +} + +export const buildPhaseDisplayName: Record = { + [BuildPhase.UNKNOWN]: 'Unknown build phase', + [BuildPhase.QUEUE]: 'Waiting to start', + [BuildPhase.SPIN_UP_BUILDER]: 'Spin up build environment', + [BuildPhase.SET_UP_BUILD_ENVIRONMENT]: 'Set up build environment', + [BuildPhase.BUILDER_INFO]: 'Builder environment info', + [BuildPhase.START_BUILD]: 'Start build', + [BuildPhase.INSTALL_CUSTOM_TOOLS]: 'Install custom tools', + [BuildPhase.PREPARE_PROJECT]: 'Prepare project', + [BuildPhase.RESTORE_CACHE]: 'Restore cache', + [BuildPhase.INSTALL_DEPENDENCIES]: 'Install dependencies', + [BuildPhase.EAS_BUILD_INTERNAL]: 'Resolve build configuration', + [BuildPhase.PREBUILD]: 'Prebuild', + [BuildPhase.PREPARE_CREDENTIALS]: 'Prepare credentials', + [BuildPhase.CALCULATE_EXPO_UPDATES_RUNTIME_VERSION]: 'Calculate expo-updates runtime version', + [BuildPhase.CONFIGURE_EXPO_UPDATES]: 'Configure expo-updates', + [BuildPhase.EAGER_BUNDLE]: 'Bundle JavaScript', + [BuildPhase.SAVE_CACHE]: 'Save cache', + [BuildPhase.CACHE_STATS]: 'Cache stats', + [BuildPhase.UPLOAD_ARTIFACTS]: 'Upload artifacts', + [BuildPhase.UPLOAD_APPLICATION_ARCHIVE]: 'Upload application archive', + [BuildPhase.UPLOAD_BUILD_ARTIFACTS]: 'Upload build artifacts', + [BuildPhase.PREPARE_ARTIFACTS]: 'Prepare artifacts', + [BuildPhase.CLEAN_UP_CREDENTIALS]: 'Clean up credentials', + [BuildPhase.COMPLETE_BUILD]: 'Complete build', + [BuildPhase.FAIL_BUILD]: 'Fail job', + [BuildPhase.READ_APP_CONFIG]: 'Read app config', + [BuildPhase.READ_PACKAGE_JSON]: 'Read package.json', + [BuildPhase.RUN_EXPO_DOCTOR]: 'Run expo doctor', + [BuildPhase.DOWNLOAD_APPLICATION_ARCHIVE]: 'Download application archive', + + // ANDROID + [BuildPhase.FIX_GRADLEW]: 'Fix gradlew', + [BuildPhase.RUN_GRADLEW]: 'Run gradlew', + + // IOS + [BuildPhase.INSTALL_PODS]: 'Install pods', + [BuildPhase.CONFIGURE_XCODE_PROJECT]: 'Configure Xcode project', + [BuildPhase.RUN_FASTLANE]: 'Run fastlane', + + // HOOKS + [BuildPhase.PRE_INSTALL_HOOK]: 'Pre-install hook', + [BuildPhase.POST_INSTALL_HOOK]: 'Post-install hook', + [BuildPhase.PRE_UPLOAD_ARTIFACTS_HOOK]: 'Pre-upload-artifacts hook', + [BuildPhase.ON_BUILD_SUCCESS_HOOK]: 'Build success hook', + [BuildPhase.ON_BUILD_ERROR_HOOK]: 'Build error hook', + [BuildPhase.ON_BUILD_COMPLETE_HOOK]: 'Build complete hook', + [BuildPhase.ON_BUILD_CANCEL_HOOK]: 'Build cancel hook', + + // CUSTOM + [BuildPhase.CUSTOM]: 'Unknown build phase', + [BuildPhase.PARSE_CUSTOM_WORKFLOW_CONFIG]: 'Parse custom build config', + [BuildPhase.COMPLETE_JOB]: 'Complete job', +}; + +export const submissionPhaseDisplayName: Record = { + [SubmissionPhase.SPIN_UP_SUBMISSION_WORKER]: 'Spin up submission worker', + [SubmissionPhase.SUBMIT_TO_PLAY_STORE]: 'Submit to Play Store', + [SubmissionPhase.SUBMIT_TO_APP_STORE]: 'Submit to App Store', + [SubmissionPhase.FAIL_SUBMISSION]: 'Fail submission', +}; + +export const buildPhaseWebsiteId: Record = { + [BuildPhase.UNKNOWN]: 'unknown', + [BuildPhase.QUEUE]: 'waiting-to-start', + [BuildPhase.SPIN_UP_BUILDER]: 'spin-up-build-environment', + [BuildPhase.SET_UP_BUILD_ENVIRONMENT]: 'set-up-build-environment', + [BuildPhase.BUILDER_INFO]: 'builder-environment-info', + [BuildPhase.START_BUILD]: 'start-build', + [BuildPhase.INSTALL_CUSTOM_TOOLS]: 'install-custom-tools', + [BuildPhase.PREPARE_PROJECT]: 'prepare-project', + [BuildPhase.RESTORE_CACHE]: 'restore-cache', + [BuildPhase.INSTALL_DEPENDENCIES]: 'install-dependencies', + [BuildPhase.EAS_BUILD_INTERNAL]: 'resolve-build-configuration', + [BuildPhase.PREBUILD]: 'prebuild', + [BuildPhase.PREPARE_CREDENTIALS]: 'prepare-credentials', + [BuildPhase.CALCULATE_EXPO_UPDATES_RUNTIME_VERSION]: 'calculate-expo-updates-runtime-version', + [BuildPhase.CONFIGURE_EXPO_UPDATES]: 'configure-expo-updates', + [BuildPhase.EAGER_BUNDLE]: 'eager-bundle', + [BuildPhase.SAVE_CACHE]: 'save-cache', + [BuildPhase.CACHE_STATS]: 'cache-stats', + [BuildPhase.UPLOAD_ARTIFACTS]: 'upload-artifacts', + [BuildPhase.UPLOAD_APPLICATION_ARCHIVE]: 'upload-application-archive', + [BuildPhase.UPLOAD_BUILD_ARTIFACTS]: 'upload-build-artifacts', + [BuildPhase.PREPARE_ARTIFACTS]: 'prepare-artifacts', + [BuildPhase.CLEAN_UP_CREDENTIALS]: 'clean-up-credentials', + [BuildPhase.COMPLETE_BUILD]: 'complete-build', + [BuildPhase.FAIL_BUILD]: 'fail-build', + [BuildPhase.READ_APP_CONFIG]: 'read-app-config', + [BuildPhase.READ_PACKAGE_JSON]: 'read-package-json', + [BuildPhase.RUN_EXPO_DOCTOR]: 'run-expo-doctor', + [BuildPhase.DOWNLOAD_APPLICATION_ARCHIVE]: 'download-application-archive', + + // ANDROID + [BuildPhase.FIX_GRADLEW]: 'fix-gradlew', + [BuildPhase.RUN_GRADLEW]: 'run-gradlew', + + // IOS + [BuildPhase.INSTALL_PODS]: 'install-pods', + [BuildPhase.CONFIGURE_XCODE_PROJECT]: 'configure-xcode-project', + [BuildPhase.RUN_FASTLANE]: 'run-fastlane', + + // HOOKS + [BuildPhase.PRE_INSTALL_HOOK]: 'pre-install-hook', + [BuildPhase.POST_INSTALL_HOOK]: 'post-install-hook', + [BuildPhase.PRE_UPLOAD_ARTIFACTS_HOOK]: 'pre-upload-artifacts-hook', + [BuildPhase.ON_BUILD_SUCCESS_HOOK]: 'build-success-hook', + [BuildPhase.ON_BUILD_ERROR_HOOK]: 'build-error-hook', + [BuildPhase.ON_BUILD_COMPLETE_HOOK]: 'build-complete-hook', + [BuildPhase.ON_BUILD_CANCEL_HOOK]: 'build-cancel-hook', + + // CUSTOM + [BuildPhase.CUSTOM]: 'custom', + [BuildPhase.PARSE_CUSTOM_WORKFLOW_CONFIG]: 'parse-custom-workflow-config', + [BuildPhase.COMPLETE_JOB]: 'complete-job', +}; + +export const submissionPhaseWebsiteId: Record = { + [SubmissionPhase.SPIN_UP_SUBMISSION_WORKER]: 'spin-up-submission-worker', + [SubmissionPhase.SUBMIT_TO_PLAY_STORE]: 'submit-to-play-store', + [SubmissionPhase.SUBMIT_TO_APP_STORE]: 'submit-to-app-store', + [SubmissionPhase.FAIL_SUBMISSION]: 'fail-submission', +}; + +export const XCODE_LOGS_BUILD_PHASE_WEBSITE_ID = 'xcode-logs'; + +export enum BuildPhaseResult { + SUCCESS = 'success', + FAIL = 'failed', + WARNING = 'warning', + SKIPPED = 'skipped', + UNKNOWN = 'unknown', +} + +export enum LogMarker { + START_PHASE = 'START_PHASE', + END_PHASE = 'END_PHASE', +} diff --git a/packages/eas-build-job/src/metadata.ts b/packages/eas-build-job/src/metadata.ts new file mode 100644 index 0000000000..61aee48715 --- /dev/null +++ b/packages/eas-build-job/src/metadata.ts @@ -0,0 +1,218 @@ +import Joi from 'joi'; + +import { Workflow } from './common'; + +export type Metadata = { + /** + * Tracking context + * It's used to track build process across different Expo services and tools. + */ + trackingContext?: Record; + + /** + * Application version: + * - managed projects: expo.version in app.json/app.config.js + * - generic projects: + * * iOS: CFBundleShortVersionString in Info.plist + * * Android: versionName in build.gradle + */ + appVersion?: string; + + /** + * Application build version: + * - Android: version code + * - iOS: build number + */ + appBuildVersion?: string; + + /** + * EAS CLI version + */ + cliVersion?: string; + + /** + * Build workflow + * It's either 'generic' or 'managed' + */ + workflow?: Workflow; + + /** + * Credentials source + * Credentials could be obtained either from credential.json or EAS servers. + */ + credentialsSource?: 'local' | 'remote'; + + /** + * Expo SDK version + * It's determined by the expo package version in package.json. + * It's undefined if the expo package is not installed for the project. + */ + sdkVersion?: string; + + /** + * Runtime version (for Expo Updates) + */ + runtimeVersion?: string; + + /** + * Fingerprint hash of a project's native dependencies + */ + fingerprintHash?: string; + + /** + * Version of the react-native package used in the project. + */ + reactNativeVersion?: string; + + /** + * Channel (for Expo Updates when it is configured for for use with EAS) + * It's undefined if the expo-updates package is not configured for use with EAS. + */ + channel?: string; + + /** + * Distribution type + * Indicates whether this is a build for store, internal distribution, or simulator (iOS). + * simulator is deprecated, use simulator flag instead + */ + distribution?: 'store' | 'internal' | 'simulator'; + + /** + * App name (expo.name in app.json/app.config.js) + */ + appName?: string; + + /** + * App identifier: + * - iOS builds: the bundle identifier (expo.ios.bundleIdentifier in app.json/app.config.js) + * - Android builds: the application id (expo.android.package in app.json/app.config.js) + */ + appIdentifier?: string; + + /** + * Build profile name (e.g. release) + */ + buildProfile?: string; + + /** + * Git commit hash (e.g. aab03fbdabb6e536ea78b28df91575ad488f5f21) + */ + gitCommitHash?: string; + + /** + * Git commit message + */ + gitCommitMessage?: string; + + /** + * State of the git working tree + */ + isGitWorkingTreeDirty?: boolean; + + /** + * Username of the initiating user + */ + username?: string; + + /** + * Indicates what type of an enterprise provisioning profile was used to build the app. + * It's either adhoc or universal + */ + iosEnterpriseProvisioning?: 'adhoc' | 'universal'; + + /** + * Message attached to the build. + */ + message?: string; + + /** + * Indicates whether the build was run from CI. + */ + runFromCI?: boolean; + + /** + * Indicates whether the build was run with --no-wait flag. + */ + runWithNoWaitFlag?: boolean; + + /** + * Workflow name available for custom builds. + */ + customWorkflowName?: string; + + /** + * Indicates whether this is (likely, we can't be 100% sure) development client build. + */ + developmentClient?: boolean; + + /** + * Which package manager will be used for the build. Determined based on lockfiles in the project directory. + */ + requiredPackageManager?: 'npm' | 'pnpm' | 'yarn' | 'bun'; + + /** + * Indicates if this is an iOS build for a simulator + */ + simulator?: boolean; + + /** + * Image selected by user for the build. If user didn't select any image and wants to use default for the given RN and SDK version it will undefined. + */ + selectedImage?: string; + + /** + * Custom node version selected by user for the build. If user didn't select any node version and wants to use default it will be undefined. + */ + customNodeVersion?: string; + + /** + * EAS env vars environment chosen for the job + */ + environment?: string; +}; + +export const MetadataSchema = Joi.object({ + trackingContext: Joi.object().pattern(Joi.string(), [Joi.string(), Joi.number(), Joi.boolean()]), + appVersion: Joi.string(), + appBuildVersion: Joi.string(), + cliVersion: Joi.string(), + workflow: Joi.string().valid('generic', 'managed'), + distribution: Joi.string().valid('store', 'internal', 'simulator'), + credentialsSource: Joi.string().valid('local', 'remote'), + sdkVersion: Joi.string(), + runtimeVersion: Joi.string(), + fingerprintHash: Joi.string(), + reactNativeVersion: Joi.string(), + channel: Joi.string(), + appName: Joi.string(), + appIdentifier: Joi.string(), + buildProfile: Joi.string(), + gitCommitHash: Joi.string().length(40).hex(), + gitCommitMessage: Joi.string().max(4096), + isGitWorkingTreeDirty: Joi.boolean(), + username: Joi.string(), + iosEnterpriseProvisioning: Joi.string().valid('adhoc', 'universal'), + message: Joi.string().max(1024), + runFromCI: Joi.boolean(), + runWithNoWaitFlag: Joi.boolean(), + customWorkflowName: Joi.string(), + developmentClient: Joi.boolean(), + requiredPackageManager: Joi.string().valid('npm', 'pnpm', 'yarn', 'bun'), + simulator: Joi.boolean(), + selectedImage: Joi.string(), + customNodeVersion: Joi.string(), + environment: Joi.string(), +}); + +export function sanitizeMetadata(metadata: object): Metadata { + const { value, error } = MetadataSchema.validate(metadata, { + stripUnknown: true, + convert: true, + abortEarly: false, + }); + if (error) { + throw error; + } else { + return value; + } +} diff --git a/packages/eas-build-job/src/step.ts b/packages/eas-build-job/src/step.ts new file mode 100644 index 0000000000..5431f1a322 --- /dev/null +++ b/packages/eas-build-job/src/step.ts @@ -0,0 +1,185 @@ +import { z } from 'zod'; + +const CommonStepZ = z.object({ + /** + * Unique identifier for the step. + * + * @example + * id: step1 + */ + id: z + .string() + .optional() + .describe( + `ID of the step. Useful for later referencing the job's outputs. Example: step with id "setup" and an output "platform" will expose its value under "steps.setup.outputs.platform".` + ), + /** + * Expression that determines whether the step should run. + * Based on the GitHub Actions job step `if` field (https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsif). + */ + if: z.string().optional().describe('Expression that determines whether the step should run.'), + /** + * The name of the step. + * + * @example + * name: 'Step 1' + */ + name: z.string().optional().describe('Name of the step.'), + /** + * The working directory to run the step in. + * + * @example + * working_directory: ./my-working-directory + * + * @default depends on the project settings + */ + working_directory: z + .string() + .optional() + .describe( + `Working directory to run the step in. Relative paths like "./assets" or "assets" are resolved from the app's base directory. Absolute paths like "/apps/mobile" are resolved from the repository root.` + ), + /** + * Env variables override for the step. + * + * @example + * env: + * MY_ENV_VAR: my-value + * ANOTHER_ENV_VAR: another-value + */ + env: z + .record(z.string(), z.string()) + .optional() + .describe('Additional environment variables to set for the step.'), +}); + +export const FunctionStepZ = CommonStepZ.extend({ + /** + * The custom EAS function to run as a step. + * It can be a function provided by EAS or a custom function defined by the user. + * + * @example + * uses: eas/build + * + * @example + * uses: my-custom-function + */ + uses: z + .string() + .describe( + 'Name of the function to use for this step. See https://docs.expo.dev/custom-builds/schema/#built-in-eas-functions for available functions.' + ), + /** + * The arguments to pass to the function. + * + * @example + * with: + * arg1: value1 + * arg2: ['ala', 'ma', 'kota'] + * arg3: + * key1: value1 + * key2: + * - value1 + * arg4: ${{ steps.step1.outputs.test }} + */ + with: z.record(z.string(), z.unknown()).optional().describe('Inputs to the function.'), + + run: z.never().optional(), + shell: z.never().optional(), + outputs: z.never().optional(), +}); + +export type FunctionStep = z.infer; + +export const ShellStepZ = CommonStepZ.extend({ + /** + * The command-line programs to run as a step. + * + * @example + * run: echo Hello, world! + * + * @example + * run: | + * npm install + * npx expo prebuild + * pod install + */ + run: z.string().describe('Shell script to run in the step.'), + /** + * The shell to run the "run" command with. + * + * @example + * shell: 'sh' + * + * @default 'bash' + */ + shell: z.string().optional().describe('Shell to run the "run" command with.'), + /** + * The outputs of the step. + * + * @example + * outputs: + * - name: my_output + * required: true + * - name: my_optional_output + * required: false + * - name: my_optional_output_without_required + */ + outputs: z + .array( + z.union([ + // We allow a shorthand for outputs + z.codec(z.string(), z.object({ name: z.string(), required: z.boolean().default(false) }), { + decode: (name) => ({ name, required: false }), + encode: (output) => output.name, + }), + z.object({ + name: z.string(), + required: z.boolean().optional(), + }), + ]) + ) + .optional(), + + uses: z.never().optional(), + with: z.never().optional(), +}); + +export type ShellStep = z.infer; + +export const StepZ = z.union([ShellStepZ, FunctionStepZ]); + +/** + * Structure of a custom EAS job step. + * + * GHA step fields skipped here: + * - `with.entrypoint` + * - `continue-on-error` + * - `timeout-minutes` + * + * * @example + * steps: + * - uses: eas/maestro-test + * id: step1 + * name: Step 1 + * with: + * flow_path: | + * maestro/sign_in.yaml + * maestro/create_post.yaml + * maestro/sign_out.yaml + * - run: echo Hello, world! + */ +export type Step = z.infer; + +export function validateSteps(maybeSteps: unknown): Step[] { + const steps = z.array(StepZ).min(1).parse(maybeSteps); + return steps; +} + +export function isStepShellStep(step: Step): step is ShellStep { + return step.run !== undefined; +} + +export function isStepFunctionStep(step: Step): step is FunctionStep { + return step.uses !== undefined; +} diff --git a/packages/eas-build-job/src/submission-config.ts b/packages/eas-build-job/src/submission-config.ts new file mode 100644 index 0000000000..ebd4be69d4 --- /dev/null +++ b/packages/eas-build-job/src/submission-config.ts @@ -0,0 +1,73 @@ +import { z } from 'zod'; + +/** Submission config as used by the submission worker. */ +export namespace SubmissionConfig { + export type Ios = z.infer; + export type Android = z.infer; + + export namespace Ios { + export const SchemaZ = z + .object({ + /** + * App Store Connect unique App ID + */ + ascAppIdentifier: z.string(), + isVerboseFastlaneEnabled: z.boolean().optional(), + groups: z.array(z.string()).optional(), + changelog: z.string().optional(), + }) + .and( + z.union([ + z.object({ + // The `appleIdUsername` & `appleAppSpecificPassword` pair is mutually exclusive with `ascApiJsonKey` + appleIdUsername: z.string(), + appleAppSpecificPassword: z.string(), + ascApiJsonKey: z.never().optional(), + }), + z.object({ + /** + * ASC API JSON token example: + * { + * key_id: "D383SF739", + * issuer_id: "6053b7fe-68a8-4acb-89be-165aa6465141", + * key: "-----BEGIN PRIVATE KEY-----\nMIGTAgEAMBMGByqGSM\n-----END PRIVATE KEY--" + * } + */ + ascApiJsonKey: z.string(), + appleIdUsername: z.never().optional(), + appleAppSpecificPassword: z.never().optional(), + }), + ]) + ); + } + + export namespace Android { + export enum ReleaseStatus { + COMPLETED = 'completed', + DRAFT = 'draft', + HALTED = 'halted', + IN_PROGRESS = 'inProgress', + } + + export const SchemaZ = z + .object({ + track: z.string(), + changesNotSentForReview: z.boolean().default(false), + googleServiceAccountKeyJson: z.string(), + isVerboseFastlaneEnabled: z.boolean().optional(), + changelog: z.string().optional(), + }) + .and( + z.union([ + z.object({ + releaseStatus: z.literal(ReleaseStatus.IN_PROGRESS), + rollout: z.number().gte(0).lte(1).default(1), + }), + z.object({ + releaseStatus: z.nativeEnum(ReleaseStatus).optional(), + rollout: z.never().optional(), + }), + ]) + ); + } +} diff --git a/packages/eas-build-job/tsconfig.build.json b/packages/eas-build-job/tsconfig.build.json new file mode 100644 index 0000000000..2ef7345294 --- /dev/null +++ b/packages/eas-build-job/tsconfig.build.json @@ -0,0 +1,4 @@ +{ + "extends": "./tsconfig.json", + "exclude": ["src/**/__mocks__/**/*.ts", "src/**/__tests__/**/*.ts"] +} diff --git a/packages/eas-build-job/tsconfig.json b/packages/eas-build-job/tsconfig.json new file mode 100644 index 0000000000..c0b18842df --- /dev/null +++ b/packages/eas-build-job/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "moduleResolution": "node", + "target": "es2022", + "module": "commonjs", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "declaration": true, + "composite": true, + "outDir": "./dist", + "rootDir": "./src" + }, + "include": ["src/**/*"] +} diff --git a/packages/eas-cli/package.json b/packages/eas-cli/package.json index 6455f0496e..d3468ff7cf 100644 --- a/packages/eas-cli/package.json +++ b/packages/eas-cli/package.json @@ -12,11 +12,11 @@ "@expo/code-signing-certificates": "0.0.5", "@expo/config": "10.0.6", "@expo/config-plugins": "9.0.12", - "@expo/eas-build-job": "1.0.243", + "@expo/eas-build-job": "1.0.260", "@expo/eas-json": "16.25.0", "@expo/env": "^1.0.0", "@expo/json-file": "8.3.3", - "@expo/logger": "1.0.221", + "@expo/logger": "1.0.260", "@expo/multipart-body-parser": "2.0.0", "@expo/osascript": "2.1.4", "@expo/package-manager": "1.7.0", @@ -28,7 +28,7 @@ "@expo/results": "1.0.0", "@expo/rudder-sdk-node": "1.1.1", "@expo/spawn-async": "1.7.2", - "@expo/steps": "1.0.231", + "@expo/steps": "1.0.260", "@expo/timeago.js": "1.0.0", "@oclif/core": "^1.26.2", "@oclif/plugin-autocomplete": "^2.3.10", diff --git a/packages/eas-json/package.json b/packages/eas-json/package.json index 6376dc4848..fcbf05d694 100644 --- a/packages/eas-json/package.json +++ b/packages/eas-json/package.json @@ -6,7 +6,7 @@ "bugs": "https://github.com/expo/eas-cli/issues", "dependencies": { "@babel/code-frame": "7.23.5", - "@expo/eas-build-job": "1.0.231", + "@expo/eas-build-job": "1.0.260", "chalk": "4.1.2", "env-string": "1.0.1", "fs-extra": "11.2.0", diff --git a/packages/local-build-plugin/README.md b/packages/local-build-plugin/README.md new file mode 100644 index 0000000000..a490956538 --- /dev/null +++ b/packages/local-build-plugin/README.md @@ -0,0 +1,7 @@ +# eas-cli-local-build-plugin + +`eas-cli-local-build-plugin` is a light wrapper around `@expo/build-tools` library that runs builds locally. The job object is passed as a base64-encoded string argument. This tool is not intended for direct use, it's used internally by `eas build --local`. + +## Repository + +https://github.com/expo/eas-cli/tree/main/packages/local-build-plugin diff --git a/packages/local-build-plugin/bin/run b/packages/local-build-plugin/bin/run new file mode 100755 index 0000000000..efb207c4af --- /dev/null +++ b/packages/local-build-plugin/bin/run @@ -0,0 +1,12 @@ +#!/usr/bin/env node + +function red(text) { + return '\u001b[31m' + text + '\u001b[39m'; +} + +if (!require('semver').gte(process.version, '12.9.0')) { + console.error(red('Node.js version 12.9.0 or newer is required.')); + process.exit(1); +} + +require('../dist/main.js'); diff --git a/packages/local-build-plugin/package.json b/packages/local-build-plugin/package.json new file mode 100644 index 0000000000..f11387249c --- /dev/null +++ b/packages/local-build-plugin/package.json @@ -0,0 +1,62 @@ +{ + "name": "eas-cli-local-build-plugin", + "repository": { + "type": "git", + "url": "https://github.com/expo/eas-cli.git", + "directory": "packages/local-build-plugin" + }, + "version": "1.0.260", + "description": "Tool for running EAS compatible builds on a local machine.", + "main": "dist/main.js", + "files": [ + "dist", + "bin" + ], + "bin": { + "eas-cli-local-build-plugin": "./bin/run" + }, + "scripts": { + "start": "yarn watch", + "watch": "tsc --watch --preserveWatchOutput", + "build": "tsc", + "prepack": "rm -rf dist && tsc -p tsconfig.build.json", + "clean": "rm -rf node_modules dist coverage" + }, + "engines": { + "node": ">=18" + }, + "author": "Expo ", + "bugs": "https://github.com/expo/eas-cli/issues", + "license": "BUSL-1.1", + "dependencies": { + "@expo/build-tools": "1.0.260", + "@expo/eas-build-job": "1.0.260", + "@expo/spawn-async": "^1.7.2", + "@expo/turtle-spawn": "1.0.260", + "bunyan": "^1.8.15", + "chalk": "4.1.2", + "env-paths": "^2.2.1", + "fs-extra": "^11.2.0", + "joi": "^17.13.1", + "lodash": "^4.17.21", + "nullthrows": "^1.1.1", + "semver": "^7.6.2", + "tar": "^7.2.0", + "uuid": "^9.0.1" + }, + "devDependencies": { + "@types/bunyan": "^1.8.11", + "@types/fs-extra": "^11.0.4", + "@types/hapi__joi": "^17.1.14", + "@types/lodash": "^4.17.4", + "@types/semver": "^7.5.8", + "@types/uuid": "^9.0.7", + "jest": "^29.7.0", + "ts-jest": "^29.1.4", + "typescript": "^5.5.4" + }, + "volta": { + "node": "20.14.0", + "yarn": "1.22.21" + } +} diff --git a/packages/local-build-plugin/src/android.ts b/packages/local-build-plugin/src/android.ts new file mode 100644 index 0000000000..2ca0a801d6 --- /dev/null +++ b/packages/local-build-plugin/src/android.ts @@ -0,0 +1,44 @@ +import { Android, ManagedArtifactType, BuildPhase, Env } from '@expo/eas-build-job'; +import { Builders, BuildContext, Artifacts } from '@expo/build-tools'; +import omit from 'lodash/omit'; + +import { logBuffer } from './logger'; +import { BuildParams } from './types'; +import { prepareArtifacts } from './artifacts'; +import config from './config'; + +export async function buildAndroidAsync( + job: Android.Job, + { workingdir, env: baseEnv, metadata, logger }: BuildParams +): Promise { + const versionName = job.version?.versionName; + const versionCode = job.version?.versionCode; + const env: Env = { + ...baseEnv, + ...(versionCode && { EAS_BUILD_ANDROID_VERSION_CODE: versionCode }), + ...(versionName && { EAS_BUILD_ANDROID_VERSION_NAME: versionName }), + }; + const ctx = new BuildContext(job, { + workingdir, + logger, + logBuffer, + uploadArtifact: async ({ artifact, logger }) => { + if (artifact.type === ManagedArtifactType.APPLICATION_ARCHIVE) { + return await prepareArtifacts(artifact.paths, logger); + } else if (artifact.type === ManagedArtifactType.BUILD_ARTIFACTS) { + return await prepareArtifacts(artifact.paths, logger); + } else { + return { filename: null }; + } + }, + env, + metadata, + skipNativeBuild: config.skipNativeBuild, + }); + + await ctx.runBuildPhase(BuildPhase.START_BUILD, async () => { + ctx.logger.info({ job: omit(ctx.job, 'secrets') }, 'Starting build'); + }); + + return await Builders.androidBuilder(ctx); +} diff --git a/packages/local-build-plugin/src/artifacts.ts b/packages/local-build-plugin/src/artifacts.ts new file mode 100644 index 0000000000..6eb29ad4c3 --- /dev/null +++ b/packages/local-build-plugin/src/artifacts.ts @@ -0,0 +1,59 @@ +import path from 'path'; + +import { bunyan } from '@expo/logger'; +import fs from 'fs-extra'; +import * as tar from 'tar'; + +import config from './config'; + +export async function prepareArtifacts( + artifactPaths: string[], + logger?: bunyan +): Promise<{ filename: string }> { + const l = logger?.child({ phase: 'PREPARE_ARTIFACTS' }); + l?.info('Preparing artifacts'); + let suffix; + let localPath; + if (artifactPaths.length === 1 && !(await fs.lstat(artifactPaths[0])).isDirectory()) { + [localPath] = artifactPaths; + suffix = path.extname(artifactPaths[0]); + } else { + const parentDir = artifactPaths.reduce( + (acc, item) => getCommonParentDir(acc, item), + artifactPaths[0] + ); + const relativePathsToArchive = artifactPaths.map((absolute) => + path.relative(parentDir, absolute) + ); + + const archivePath = path.join(config.workingdir, 'artifacts.tar.gz'); + await tar.c( + { + gzip: true, + file: archivePath, + cwd: parentDir, + }, + relativePathsToArchive + ); + suffix = '.tar.gz'; + localPath = archivePath; + } + const artifactName = `build-${Date.now()}${suffix}`; + const destPath = config.artifactPath ?? path.join(config.artifactsDir, artifactName); + await fs.copy(localPath, destPath); + l?.info({ phase: 'PREPARE_ARTIFACTS' }, `Writing artifacts to ${destPath}`); + return { filename: destPath }; +} + +function getCommonParentDir(path1: string, path2: string): string { + const normalizedPath1 = path.normalize(path1); + const normalizedPath2 = path.normalize(path2); + let current = path.dirname(normalizedPath1); + while (current !== '/') { + if (normalizedPath2.startsWith(current)) { + return current; + } + current = path.dirname(current); + } + return '/'; +} diff --git a/packages/local-build-plugin/src/build.ts b/packages/local-build-plugin/src/build.ts new file mode 100644 index 0000000000..9542e65cc8 --- /dev/null +++ b/packages/local-build-plugin/src/build.ts @@ -0,0 +1,92 @@ +import path from 'path'; + +import { BuildJob, Platform, ArchiveSourceType, Metadata, Workflow } from '@expo/eas-build-job'; +import pickBy from 'lodash/pickBy'; +import fs from 'fs-extra'; +import chalk from 'chalk'; +import { Artifacts, SkipNativeBuildError } from '@expo/build-tools'; +import { DEBUG } from 'bunyan'; + +import { buildAndroidAsync } from './android'; +import config from './config'; +import { buildIosAsync } from './ios'; +import { prepareWorkingdirAsync } from './workingdir'; +import { createLogger } from './logger'; + +export async function buildAsync(job: BuildJob, metadata: Metadata): Promise { + const logger = createLogger(job.loggerLevel); + const workingdir = await prepareWorkingdirAsync({ logger }); + + try { + let username = metadata.username; + if (!username && job.type === Workflow.MANAGED) { + username = job.username; + } + + // keep in sync with worker env vars + // https://github.com/expo/turtle-v2/blob/main/src/services/worker/src/env.ts + const unfilteredEnv: Record = { + ...process.env, + ...job.builderEnvironment?.env, + // We don't use Node.js mirror locally, but a user might + NVM_NODEJS_ORG_MIRROR: process.env.NVM_NODEJS_ORG_MIRROR, + EAS_BUILD: '1', + EAS_BUILD_RUNNER: 'local-build-plugin', + EAS_BUILD_PLATFORM: job.platform, + EAS_BUILD_WORKINGDIR: path.join(workingdir, 'build'), + EAS_BUILD_PROFILE: metadata.buildProfile, + EAS_BUILD_GIT_COMMIT_HASH: metadata.gitCommitHash, + EAS_BUILD_USERNAME: username, + ...(job.platform === Platform.ANDROID && { + EAS_BUILD_ANDROID_VERSION_CODE: job.version?.versionCode, + EAS_BUILD_ANDROID_VERSION_NAME: job.version?.versionName, + }), + ...(job.platform === Platform.IOS && { + EAS_BUILD_IOS_BUILD_NUMBER: job.version?.buildNumber, + EAS_BUILD_IOS_APP_VERSION: job.version?.appVersion, + }), + }; + const env = pickBy(unfilteredEnv, (val?: string): val is string => !!val); + + let artifacts: Artifacts | undefined; + switch (job.platform) { + case Platform.ANDROID: { + artifacts = await buildAndroidAsync(job, { env, workingdir, metadata, logger }); + break; + } + case Platform.IOS: { + artifacts = await buildIosAsync(job, { env, workingdir, metadata, logger }); + break; + } + } + if (!config.skipNativeBuild) { + console.log(); + console.log(chalk.green('Build successful')); + if (artifacts.APPLICATION_ARCHIVE) { + console.log( + chalk.green(`You can find the build artifacts in ${artifacts.APPLICATION_ARCHIVE}`) + ); + } + } + } catch (e: any) { + if (e instanceof SkipNativeBuildError) { + console.log(e.message); + return; + } + console.error(); + console.error(chalk.red(`Build failed`)); + if (logger.level() === DEBUG) { + console.error(e.innerError); + } + throw e; + } finally { + if (!config.skipCleanup) { + await fs.remove(workingdir); + } else { + console.error(chalk.yellow(`Skipping cleanup, ${workingdir} won't be removed.`)); + } + if (job.projectArchive.type === ArchiveSourceType.PATH) { + await fs.remove(job.projectArchive.path); + } + } +} diff --git a/packages/local-build-plugin/src/checkRuntime.ts b/packages/local-build-plugin/src/checkRuntime.ts new file mode 100644 index 0000000000..8bb5910880 --- /dev/null +++ b/packages/local-build-plugin/src/checkRuntime.ts @@ -0,0 +1,131 @@ +import { Job, Platform } from '@expo/eas-build-job'; +import chalk from 'chalk'; +import spawnAsync from '@expo/spawn-async'; +import fs from 'fs-extra'; + +interface Validator { + platform?: Platform; + checkAsync: (job: Job) => Promise; +} + +function warn(msg: string): void { + console.log(chalk.yellow(msg)); +} + +function error(msg: string): void { + console.error(chalk.red(msg)); +} + +const validators: Validator[] = [ + { + async checkAsync(job: Job) { + if (job.platform === Platform.IOS && process.platform !== 'darwin') { + throw new Error('iOS builds can only be run on macOS.'); + } else if ( + job.platform === Platform.ANDROID && + !['linux', 'darwin'].includes(process.platform) + ) { + throw new Error('Android builds are supported only on Linux and macOS'); + } + }, + }, + { + async checkAsync(job: Job) { + try { + const version = (await spawnAsync('node', ['--version'], { stdio: 'pipe' })).stdout.trim(); + const sanitizedVersion = version.startsWith('v') ? version.slice(1) : version; + const versionFromJob = job.builderEnvironment?.node; + if (versionFromJob) { + const sanitizedVersionFromJob = versionFromJob.startsWith('v') + ? versionFromJob.slice(1) + : versionFromJob; + if (sanitizedVersion !== sanitizedVersionFromJob) { + warn( + 'Node.js version in your eas.json does not match the Node.js currently installed in your system' + ); + } + } + } catch (err) { + error("Node.js is not available, make sure it's installed and in your PATH"); + throw err; + } + }, + }, + { + async checkAsync(job: Job) { + const versionFromJob = job.builderEnvironment?.yarn; + if (!versionFromJob) { + return; + } + try { + const version = (await spawnAsync('yarn', ['--version'], { stdio: 'pipe' })).stdout.trim(); + if (versionFromJob !== version) { + warn( + 'Yarn version in your eas.json does not match the yarn currently installed in your system' + ); + } + } catch { + warn("Yarn is not available, make sure it's installed and in your PATH"); + } + }, + }, + { + platform: Platform.ANDROID, + async checkAsync(_) { + if (!process.env.ANDROID_NDK_HOME) { + warn( + 'ANDROID_NDK_HOME environment variable was not specified, continuing build without NDK' + ); + return; + } + if (!(await fs.pathExists(process.env.ANDROID_NDK_HOME))) { + throw new Error(`NDK was not found under ${process.env.ANDROID_NDK_HOME}`); + } + }, + }, + { + platform: Platform.IOS, + async checkAsync() { + try { + await spawnAsync('fastlane', ['--version'], { + stdio: 'pipe', + env: { + ...process.env, + FASTLANE_DISABLE_COLORS: '1', + FASTLANE_SKIP_UPDATE_CHECK: '1', + SKIP_SLOW_FASTLANE_WARNING: 'true', + FASTLANE_HIDE_TIMESTAMP: 'true', + }, + }); + } catch (err) { + error("Fastlane is not available, make sure it's installed and in your PATH"); + throw err; + } + }, + }, + { + platform: Platform.IOS, + async checkAsync(job: Job) { + try { + const version = (await spawnAsync('pod', ['--version'], { stdio: 'pipe' })).stdout.trim(); + const versionFromJob = job.platform === Platform.IOS && job.builderEnvironment?.cocoapods; + if (versionFromJob && versionFromJob !== version) { + warn( + 'Cocoapods version in your eas.json does not match the version currently installed in your system' + ); + } + } catch (err) { + error("Cocoapods is not available, make sure it's installed and in your PATH"); + throw err; + } + }, + }, +]; + +export async function checkRuntimeAsync(job: Job): Promise { + for (const validator of validators) { + if (validator.platform === job.platform || !validator.platform) { + await validator.checkAsync(job); + } + } +} diff --git a/packages/local-build-plugin/src/config.ts b/packages/local-build-plugin/src/config.ts new file mode 100644 index 0000000000..e7bb935d3c --- /dev/null +++ b/packages/local-build-plugin/src/config.ts @@ -0,0 +1,36 @@ +import path from 'path'; + +import { v4 as uuidv4 } from 'uuid'; +import envPaths from 'env-paths'; +import { LoggerLevel } from '@expo/logger'; + +const { temp } = envPaths('eas-build-local'); + +const envWorkingdir = process.env.EAS_LOCAL_BUILD_WORKINGDIR; +const envSkipCleanup = process.env.EAS_LOCAL_BUILD_SKIP_CLEANUP; +const envSkipNativeBuild = process.env.EAS_LOCAL_BUILD_SKIP_NATIVE_BUILD; +const envArtifactsDir = process.env.EAS_LOCAL_BUILD_ARTIFACTS_DIR; +const envArtifactPath = process.env.EAS_LOCAL_BUILD_ARTIFACT_PATH; + +if ( + process.env.EAS_LOCAL_BUILD_LOGGER_LEVEL && + !Object.values(LoggerLevel).includes(process.env.EAS_LOCAL_BUILD_LOGGER_LEVEL as LoggerLevel) +) { + throw new Error( + `Invalid value for EAS_LOCAL_BUILD_LOGGER_LEVEL, one of ${Object.values(LoggerLevel) + .map((ll) => `"${ll}"`) + .join(', ')} is expected` + ); +} +const envLoggerLevel = process.env.EAS_LOCAL_BUILD_LOGGER_LEVEL as LoggerLevel; + +export default { + workingdir: envWorkingdir ?? path.join(temp, uuidv4()), + skipCleanup: envSkipCleanup === '1', + skipNativeBuild: envSkipNativeBuild === '1', + artifactsDir: envArtifactsDir ?? process.cwd(), + artifactPath: envArtifactPath, + logger: { + defaultLoggerLevel: envLoggerLevel ?? LoggerLevel.INFO, + }, +}; diff --git a/packages/local-build-plugin/src/exit.ts b/packages/local-build-plugin/src/exit.ts new file mode 100644 index 0000000000..b94767ae43 --- /dev/null +++ b/packages/local-build-plugin/src/exit.ts @@ -0,0 +1,39 @@ +import { createLogger } from './logger'; + +const handlers: (() => void | Promise)[] = []; +let shouldExitStatus = false; + +export function listenForInterrupts(): void { + let handlerInProgress = false; + const handleExit = async (): Promise => { + try { + // when eas-cli calls childProcess.kill() local build receives + // signal twice in some cases + if (handlerInProgress) { + return; + } + handlerInProgress = true; + createLogger().error({ phase: 'ABORT' }, 'Received termination signal.'); + shouldExitStatus = true; + await Promise.allSettled( + handlers.map((handler) => { + return handler(); + }) + ); + } finally { + handlerInProgress = false; + } + process.exit(1); + }; + + process.on('SIGTERM', handleExit); + process.on('SIGINT', handleExit); +} + +export function registerHandler(fn: () => void | Promise): void { + handlers.push(fn); +} + +export function shouldExit(): boolean { + return shouldExitStatus; +} diff --git a/packages/local-build-plugin/src/ios.ts b/packages/local-build-plugin/src/ios.ts new file mode 100644 index 0000000000..ab917771ba --- /dev/null +++ b/packages/local-build-plugin/src/ios.ts @@ -0,0 +1,44 @@ +import { Ios, BuildPhase, Env, ManagedArtifactType } from '@expo/eas-build-job'; +import { Builders, BuildContext, Artifacts } from '@expo/build-tools'; +import omit from 'lodash/omit'; + +import { logBuffer } from './logger'; +import { BuildParams } from './types'; +import { prepareArtifacts } from './artifacts'; +import config from './config'; + +export async function buildIosAsync( + job: Ios.Job, + { workingdir, env: baseEnv, metadata, logger }: BuildParams +): Promise { + const buildNumber = job.version?.buildNumber; + const appVersion = job.version?.appVersion; + const env: Env = { + ...baseEnv, + ...(buildNumber && { EAS_BUILD_IOS_BUILD_NUMBER: buildNumber }), + ...(appVersion && { EAS_BUILD_IOS_APP_VERSION: appVersion }), + }; + const ctx = new BuildContext(job, { + workingdir, + logger, + logBuffer, + uploadArtifact: async ({ artifact, logger }) => { + if (artifact.type === ManagedArtifactType.APPLICATION_ARCHIVE) { + return await prepareArtifacts(artifact.paths, logger); + } else if (artifact.type === ManagedArtifactType.BUILD_ARTIFACTS) { + return await prepareArtifacts(artifact.paths, logger); + } else { + return { filename: null }; + } + }, + env, + metadata, + skipNativeBuild: config.skipNativeBuild, + }); + + await ctx.runBuildPhase(BuildPhase.START_BUILD, async () => { + ctx.logger.info({ job: omit(ctx.job, 'secrets') }, 'Starting build'); + }); + + return await Builders.iosBuilder(ctx); +} diff --git a/packages/local-build-plugin/src/logger.ts b/packages/local-build-plugin/src/logger.ts new file mode 100644 index 0000000000..c93eb9e78f --- /dev/null +++ b/packages/local-build-plugin/src/logger.ts @@ -0,0 +1,149 @@ +import { Writable } from 'stream'; + +import bunyan from 'bunyan'; +import chalk from 'chalk'; +import omit from 'lodash/omit'; +import { LogBuffer } from '@expo/build-tools'; +import { LoggerLevel } from '@expo/logger'; + +import config from './config'; + +interface Log { + msg: string; + level: number; + err?: any; + marker?: string; + phase: string; + source: 'stdout' | 'stderr'; + buildStepDisplayName?: string; +} +const MAX_LINES_IN_BUFFER = 100; + +interface CachedLog { + msg: string; + phase: string; +} + +class BuildCliLogBuffer extends Writable implements LogBuffer { + public writable = true; + public buffer: CachedLog[] = []; + + constructor(private readonly numberOfLines: number) { + super(); + } + + public write(rec: any): boolean { + if ( + // verify required fields + typeof rec !== 'object' || + typeof rec?.msg !== 'string' || + typeof rec?.phase !== 'string' || + // use only logs from spawn commands + (rec?.source !== 'stdout' && rec?.source !== 'stderr') || + // skip all short lines (it could potentially be some loader) + (rec?.msg ?? '').length < 3 + ) { + return true; + } + if (this.buffer.length >= this.numberOfLines) { + this.buffer.shift(); + } + this.buffer.push({ msg: rec.msg, phase: rec.phase }); + return true; + } + + public getLogs(): string[] { + return this.buffer.map(({ msg }) => msg); + } + + public getPhaseLogs(buildPhase: string): string[] { + return this.buffer.filter(({ phase }) => phase === buildPhase).map(({ msg }) => msg); + } +} + +class PrettyStream extends Writable { + write(rawLog: string): boolean { + const log = JSON.parse(rawLog) as Log; + if (log.marker) { + return true; + } + const msg = this.formatMessage(log); + if (log.level >= bunyan.ERROR || log.source === 'stderr') { + console.error(msg); + } else { + console.log(msg); + } + + const extraProperties = omit(log, [ + 'msg', + 'err', + 'level', + 'phase', + 'marker', + 'source', + 'time', + 'id', + 'v', + 'pid', + 'hostname', + 'name', + 'buildStepInternalId', + 'buildStepId', + 'buildStepDisplayName', + ]); + if (Object.keys(extraProperties).length !== 0) { + const str = JSON.stringify(extraProperties, null, 2); + // substring removes `{\n` and `\n}` + console.log(chalk.gray(str.substring(2, str.length - 2))); + } + if (log?.err?.stack) { + console.error(chalk.red(log.err.stack)); + } + return true; + } + + private formatMessage(log: Log): string { + const phase = this.getPhaseName(log); + switch (log.level) { + case bunyan.DEBUG: + return `[${phase}] ${chalk.gray(log.msg)}`; + case bunyan.INFO: { + const msg = log.source === 'stderr' ? chalk.red(log.msg) : log.msg; + return `[${phase}] ${msg}`; + } + case bunyan.WARN: + return `[${phase}] ${chalk.yellow(log.msg)}`; + case bunyan.ERROR: + return `[${phase}] ${chalk.red(log.msg)}`; + case bunyan.FATAL: + return `[${phase}] ${chalk.red(log.msg)}`; + default: + return log.msg; + } + } + + private getPhaseName(log: Log): string { + return log.phase === 'CUSTOM' && log.buildStepDisplayName + ? log.buildStepDisplayName + : log.phase; + } +} + +export const logBuffer = new BuildCliLogBuffer(MAX_LINES_IN_BUFFER); + +export function createLogger(level?: LoggerLevel): bunyan { + return bunyan.createLogger({ + name: 'eas-build-cli', + serializers: bunyan.stdSerializers, + streams: [ + { + level: level ?? config.logger.defaultLoggerLevel, + stream: new PrettyStream(), + }, + { + level: LoggerLevel.INFO, + stream: logBuffer, + }, + ], + }); +} diff --git a/packages/local-build-plugin/src/main.ts b/packages/local-build-plugin/src/main.ts new file mode 100644 index 0000000000..d786111dbe --- /dev/null +++ b/packages/local-build-plugin/src/main.ts @@ -0,0 +1,24 @@ +import chalk from 'chalk'; + +import { parseInputAsync } from './parseInput'; +import { buildAsync } from './build'; +import { listenForInterrupts, shouldExit } from './exit'; +import { checkRuntimeAsync } from './checkRuntime'; + +listenForInterrupts(); + +async function main(): Promise { + try { + const { job, metadata } = await parseInputAsync(); + await checkRuntimeAsync(job); + await buildAsync(job, metadata); + } catch (err: any) { + if (!shouldExit()) { + console.error(chalk.red(err.message)); + console.log(err.stack); + process.exit(1); + } + } +} + +void main(); diff --git a/packages/local-build-plugin/src/parseInput.ts b/packages/local-build-plugin/src/parseInput.ts new file mode 100644 index 0000000000..46b110a0ed --- /dev/null +++ b/packages/local-build-plugin/src/parseInput.ts @@ -0,0 +1,85 @@ +import { + sanitizeBuildJob, + ArchiveSourceType, + Metadata, + sanitizeMetadata, + BuildJob, +} from '@expo/eas-build-job'; +import Joi from 'joi'; +import chalk from 'chalk'; +import fs from 'fs-extra'; + +import { registerHandler } from './exit'; + +const packageJson = require('../package.json'); + +interface Params { + job: BuildJob; + metadata: Metadata; +} + +const ParamsSchema = Joi.object({ + job: Joi.object().unknown(), + metadata: Joi.object().unknown(), +}); + +export async function parseInputAsync(): Promise { + if (process.argv.findIndex((arg) => arg === '--version' || arg === '-v') !== -1) { + console.log(packageJson.version); + process.exit(0); + } + const rawInput = process.argv[2]; + + if (!rawInput) { + displayHelp(); + process.exit(1); + } + let parsedParams; + try { + parsedParams = JSON.parse(Buffer.from(rawInput, 'base64').toString('utf8')); + } catch (err) { + console.error(`${chalk.red('The input passed as a argument is not base64 encoded json.')}`); + throw err; + } + const params = validateParams(parsedParams); + registerHandler(async () => { + if (params.job.projectArchive.type === ArchiveSourceType.PATH) { + await fs.remove(params.job.projectArchive.path); + } + }); + return params; +} + +function validateParams(params: object): Params { + const { value, error } = ParamsSchema.validate(params, { + stripUnknown: true, + convert: true, + abortEarly: false, + }); + if (error) { + throw error; + } + try { + const job = sanitizeBuildJob(value.job); + const metadata = sanitizeMetadata(value.metadata); + return { ...value, job, metadata }; + } catch (err) { + console.log(`Currently using ${packageJson.name}@${packageJson.version}.`); + console.error( + chalk.red( + `Job object has incorrect format, update to latest versions of ${chalk.bold( + 'eas-cli' + )} and ${chalk.bold(packageJson.name)} to make sure you are using compatible packages.` + ) + ); + throw err; + } +} + +function displayHelp(): void { + console.log( + `This tool is not intended for standalone use, it will be used internally by ${chalk.bold( + 'eas-cli' + )} when building with flag ${chalk.bold('--local')}.` + ); +} diff --git a/packages/local-build-plugin/src/types.ts b/packages/local-build-plugin/src/types.ts new file mode 100644 index 0000000000..644a593711 --- /dev/null +++ b/packages/local-build-plugin/src/types.ts @@ -0,0 +1,9 @@ +import { Metadata } from '@expo/eas-build-job'; +import { bunyan } from '@expo/logger'; + +export interface BuildParams { + workingdir: string; + env: Record; + metadata: Metadata; + logger: bunyan; +} diff --git a/packages/local-build-plugin/src/workingdir.ts b/packages/local-build-plugin/src/workingdir.ts new file mode 100644 index 0000000000..6f13d70572 --- /dev/null +++ b/packages/local-build-plugin/src/workingdir.ts @@ -0,0 +1,33 @@ +import path from 'path'; + +import chalk from 'chalk'; +import fs from 'fs-extra'; +import { bunyan } from '@expo/logger'; + +import config from './config'; +import { registerHandler } from './exit'; + +export async function prepareWorkingdirAsync({ logger }: { logger: bunyan }): Promise { + const { workingdir } = config; + logger.info({ phase: 'SETUP_WORKINGDIR' }, `Preparing workingdir ${workingdir}`); + + if ((await fs.pathExists(workingdir)) && (await fs.readdir(workingdir)).length > 0) { + throw new Error('Workingdir is not empty.'); + } + await fs.mkdirp(path.join(workingdir, 'artifacts')); + await fs.mkdirp(path.join(workingdir, 'build')); + await fs.mkdirp(path.join(workingdir, 'temporary-custom-build')); + await fs.mkdirp(path.join(workingdir, 'custom-build')); + await fs.mkdirp(path.join(workingdir, 'env')); + await fs.mkdirp(path.join(workingdir, 'bin')); + registerHandler(async () => { + if (!config.skipCleanup) { + await fs.remove(workingdir); + } else { + console.error( + chalk.yellow("EAS_LOCAL_BUILD_SKIP_CLEANUP is set, working dir won't be removed.") + ); + } + }); + return workingdir; +} diff --git a/packages/local-build-plugin/tsconfig.build.json b/packages/local-build-plugin/tsconfig.build.json new file mode 100644 index 0000000000..62af5254ae --- /dev/null +++ b/packages/local-build-plugin/tsconfig.build.json @@ -0,0 +1,4 @@ +{ + "extends": "./tsconfig.json", + "exclude": ["src/**/__mocks__/**/*.ts", "src/**/__test__/**/*.ts"] +} diff --git a/packages/local-build-plugin/tsconfig.json b/packages/local-build-plugin/tsconfig.json new file mode 100644 index 0000000000..88c1c45a97 --- /dev/null +++ b/packages/local-build-plugin/tsconfig.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "moduleResolution": "node", + "target": "es2022", + "module": "commonjs", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "declaration": true, + "composite": true, + "rootDir": "./src", + "outDir": "./dist" + }, + "include": [ + "src/**/*" + ] +} diff --git a/packages/logger/.eslintrc.json b/packages/logger/.eslintrc.json new file mode 100644 index 0000000000..be97c53fbb --- /dev/null +++ b/packages/logger/.eslintrc.json @@ -0,0 +1,3 @@ +{ + "extends": "../../.eslintrc.json" +} diff --git a/packages/logger/.gitignore b/packages/logger/.gitignore new file mode 100644 index 0000000000..b947077876 --- /dev/null +++ b/packages/logger/.gitignore @@ -0,0 +1,2 @@ +node_modules/ +dist/ diff --git a/packages/logger/README.md b/packages/logger/README.md new file mode 100644 index 0000000000..28346f6b53 --- /dev/null +++ b/packages/logger/README.md @@ -0,0 +1,7 @@ +# @expo/logger + +`@expo/logger` is a wrapper around `bunyan` library. + +## Repository + +https://github.com/expo/eas-cli/tree/main/packages/logger diff --git a/packages/logger/jest.config.js b/packages/logger/jest.config.js new file mode 100644 index 0000000000..97c059fc2b --- /dev/null +++ b/packages/logger/jest.config.js @@ -0,0 +1,8 @@ +module.exports = { + preset: 'ts-jest', + testEnvironment: 'node', + rootDir: 'src', + testMatch: ['**/__tests__/*.test.ts'], + clearMocks: true, + setupFilesAfterEnv: ['/../jest/setup-tests.ts'], +}; diff --git a/packages/logger/jest/setup-tests.ts b/packages/logger/jest/setup-tests.ts new file mode 100644 index 0000000000..e367ec79ab --- /dev/null +++ b/packages/logger/jest/setup-tests.ts @@ -0,0 +1,5 @@ +if (process.env.NODE_ENV !== 'test') { + throw new Error("NODE_ENV environment variable must be set to 'test'."); +} + +// Always mock: diff --git a/packages/logger/package.json b/packages/logger/package.json new file mode 100644 index 0000000000..4571ad5d2e --- /dev/null +++ b/packages/logger/package.json @@ -0,0 +1,41 @@ +{ + "name": "@expo/logger", + "repository": { + "type": "git", + "url": "https://github.com/expo/eas-cli.git", + "directory": "packages/logger" + }, + "version": "1.0.260", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "files": [ + "dist" + ], + "scripts": { + "start": "yarn watch", + "watch": "tsc --watch --preserveWatchOutput", + "build": "tsc", + "prepack": "rm -rf dist && tsc -p tsconfig.build.json", + "test": "jest --config jest.config.js --passWithNoTests", + "test:watch": "jest --config jest.config.js --watch", + "clean": "rm -rf node_modules dist coverage" + }, + "author": "Expo ", + "bugs": "https://github.com/expo/eas-cli/issues", + "license": "BUSL-1.1", + "dependencies": { + "@types/bunyan": "^1.8.11", + "bunyan": "^1.8.15" + }, + "devDependencies": { + "@types/jest": "^29.5.12", + "@types/node": "20.14.2", + "jest": "^29.7.0", + "ts-jest": "^29.1.4", + "typescript": "^5.5.4" + }, + "volta": { + "node": "20.14.0", + "yarn": "1.22.21" + } +} diff --git a/packages/logger/src/index.ts b/packages/logger/src/index.ts new file mode 100644 index 0000000000..62c60d798f --- /dev/null +++ b/packages/logger/src/index.ts @@ -0,0 +1,22 @@ +import bunyan from 'bunyan'; + +import LoggerLevel from './level'; +import { pipe, pipeSpawnOutput, PipeMode, PipeOptions } from './pipe'; + +const DEFAULT_LOGGER_NAME = 'expo-logger'; + +function createLogger(options: bunyan.LoggerOptions): bunyan { + return bunyan.createLogger({ + serializers: bunyan.stdSerializers, + ...options, + }); +} + +const defaultLogger = createLogger({ + name: DEFAULT_LOGGER_NAME, + level: LoggerLevel.INFO, +}); + +export default defaultLogger; +export { LoggerLevel, createLogger, pipe, pipeSpawnOutput, PipeMode, PipeOptions }; +export type { bunyan }; diff --git a/packages/logger/src/level.ts b/packages/logger/src/level.ts new file mode 100644 index 0000000000..a4ba2b42b3 --- /dev/null +++ b/packages/logger/src/level.ts @@ -0,0 +1,10 @@ +enum LoggerLevel { + TRACE = 'trace', + DEBUG = 'debug', + INFO = 'info', + WARN = 'warn', + ERROR = 'error', + FATAL = 'fatal', +} + +export default LoggerLevel; diff --git a/packages/logger/src/pipe.ts b/packages/logger/src/pipe.ts new file mode 100644 index 0000000000..0fe4491cd7 --- /dev/null +++ b/packages/logger/src/pipe.ts @@ -0,0 +1,91 @@ +import { Readable } from 'stream'; + +import type bunyan from 'bunyan'; + +type LineLogger = (line: string) => void; +type LineTransformer = (line: string) => string | null; +interface SpawnOutput { + stdout?: Readable | null; + stderr?: Readable | null; +} + +export interface PipeOptions { + mode?: PipeMode; + lineTransformer?: LineTransformer; + infoCallbackFn?: () => void; +} + +function pipe(stream: Readable, loggerFn: LineLogger, lineTransformer?: LineTransformer): void { + const multilineLogger = createMultilineLogger(loggerFn, lineTransformer); + stream.on('data', multilineLogger); +} + +export enum PipeMode { + /** + * Pipe both stdout and stderr to logger + */ + COMBINED, + /** + * Pipe both stdout and stderr to logger, but tag stderr as stdout + */ + COMBINED_AS_STDOUT, + /** + * Pipe stderr to logger, but tag it as stdout. Do not pipe stdout + * at all. + */ + STDERR_ONLY_AS_STDOUT, +} + +function pipeSpawnOutput( + logger: bunyan, + { stdout, stderr }: SpawnOutput = {}, + { mode = PipeMode.COMBINED, lineTransformer, infoCallbackFn }: PipeOptions = {} +): void { + if (stdout && [PipeMode.COMBINED, PipeMode.COMBINED_AS_STDOUT].includes(mode)) { + const stdoutLogger = logger.child({ source: 'stdout' }); + pipe( + stdout, + (line) => { + stdoutLogger.info(line); + infoCallbackFn?.(); + }, + lineTransformer + ); + } + if (stderr) { + const stderrLogger = logger.child({ + source: [PipeMode.STDERR_ONLY_AS_STDOUT, PipeMode.COMBINED_AS_STDOUT].includes(mode) + ? 'stdout' + : 'stderr', + }); + pipe( + stderr, + (line) => { + stderrLogger.info(line); + infoCallbackFn?.(); + }, + lineTransformer + ); + } +} + +function createMultilineLogger(loggerFn: LineLogger, transformer?: LineTransformer) { + return (data: any): void => { + if (!data) { + return; + } + const lines = String(data).trim().split('\n'); + lines.forEach((line) => { + if (transformer) { + const transformedLine = transformer(line); + if (transformedLine) { + loggerFn(transformedLine); + } + } else { + loggerFn(line); + } + }); + }; +} + +export { pipe, pipeSpawnOutput }; diff --git a/packages/logger/tsconfig.build.json b/packages/logger/tsconfig.build.json new file mode 100644 index 0000000000..2ef7345294 --- /dev/null +++ b/packages/logger/tsconfig.build.json @@ -0,0 +1,4 @@ +{ + "extends": "./tsconfig.json", + "exclude": ["src/**/__mocks__/**/*.ts", "src/**/__tests__/**/*.ts"] +} diff --git a/packages/logger/tsconfig.json b/packages/logger/tsconfig.json new file mode 100644 index 0000000000..c0b18842df --- /dev/null +++ b/packages/logger/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "moduleResolution": "node", + "target": "es2022", + "module": "commonjs", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "declaration": true, + "composite": true, + "outDir": "./dist", + "rootDir": "./src" + }, + "include": ["src/**/*"] +} diff --git a/packages/steps/.eslintrc.json b/packages/steps/.eslintrc.json new file mode 100644 index 0000000000..8c9e49df4f --- /dev/null +++ b/packages/steps/.eslintrc.json @@ -0,0 +1,10 @@ +{ + "extends": "../../.eslintrc.json", + "plugins": [ + "async-protect" + ], + "rules": { + "async-protect/async-suffix": "error", + "import/extensions": ["error", "always"] + } +} diff --git a/packages/steps/README.md b/packages/steps/README.md new file mode 100644 index 0000000000..d6374a761b --- /dev/null +++ b/packages/steps/README.md @@ -0,0 +1,15 @@ +# @expo/steps + +TBD + +### Examples + +If you want to run config examples from the **examples** directory, e.g. **examples/simple**, follow the steps: + +- Run `yarn` and `yarn build` in the root of the monorepo. +- Add `alias eas-steps="/REPLACE/WITH/PATH/TO/eas-cli/packages/steps/cli.sh"` to your **.zshrc**/**.bashrc**/etc. +- cd into **examples/simple** and run `eas-steps config.yml project [darwin|linux]`. The first argument is the config file, and the second is the default working directory for the config file. The third argument is the platform for which you want to simulate running a custom build, it defaults to the platform of the device on which the CLI is currently being run. + +### Example project + +See the example project using custom builds at https://github.com/expo/eas-custom-builds-example. diff --git a/packages/steps/bin/set-env b/packages/steps/bin/set-env new file mode 100755 index 0000000000..4d2d035412 --- /dev/null +++ b/packages/steps/bin/set-env @@ -0,0 +1,27 @@ +#!/usr/bin/env bash + +set -eo pipefail + +NAME=$1 +VALUE=$2 + +if [[ -z "$__EXPO_STEPS_ENVS_DIR" ]]; then + echo "Set __EXPO_STEPS_ENVS_DIR" + exit 1 +fi + +if [[ -z "$NAME" || -z "$VALUE" ]]; then + echo "Usage: set-env NAME VALUE" + exit 2 +fi + +if [[ "$NAME" == *"="* ]]; then + echo "Environment name can't include =" + exit 1 +fi + +if [[ "$OSTYPE" == "linux"* ]]; then + echo -n "$VALUE" | base64 -w 0 > $__EXPO_STEPS_ENVS_DIR/$NAME +else + echo -n "$VALUE" | base64 > $__EXPO_STEPS_ENVS_DIR/$NAME +fi diff --git a/packages/steps/bin/set-output b/packages/steps/bin/set-output new file mode 100755 index 0000000000..9d6c74c74e --- /dev/null +++ b/packages/steps/bin/set-output @@ -0,0 +1,22 @@ +#!/usr/bin/env bash + +set -eo pipefail + +NAME=$1 +VALUE=$2 + +if [[ -z "$__EXPO_STEPS_OUTPUTS_DIR" ]]; then + echo "Set __EXPO_STEPS_OUTPUTS_DIR" + exit 1 +fi + +if [[ -z "$NAME" || -z "$VALUE" ]]; then + echo "Usage: set-output NAME VALUE" + exit 2 +fi + +if [[ "$OSTYPE" == "linux"* ]]; then + echo -n "$VALUE" | base64 -w 0 > $__EXPO_STEPS_OUTPUTS_DIR/$NAME +else + echo -n "$VALUE" | base64 > $__EXPO_STEPS_OUTPUTS_DIR/$NAME +fi diff --git a/packages/steps/build.sh b/packages/steps/build.sh new file mode 100755 index 0000000000..13294e4405 --- /dev/null +++ b/packages/steps/build.sh @@ -0,0 +1,27 @@ +#!/usr/bin/env bash + +SED_INPLACE_OPT=(-i '') +if [[ "$OSTYPE" == "linux-gnu"* ]]; then + SED_INPLACE_OPT=(-i) +fi + +set -eo pipefail + +if [[ "$npm_lifecycle_event" == "prepack" ]]; then + echo 'Removing "dist_commonjs" and "dist_esm" folders...' + rm -rf dist_commonjs dist_esm +fi + +echo 'Compiling TypeScript to JavaScript...' +node_modules/.bin/tsc --project tsconfig.build.json + +echo 'Compiling TypeScript to CommonJS JavaScript...' +node_modules/.bin/tsc --project tsconfig.build.commonjs.json + +echo 'Renaming CommonJS file extensions to .cjs...' +find dist_commonjs -type f -name '*.js' -exec bash -c 'mv "$0" "${0%.*}.cjs"' {} \; + +echo 'Rewriting module specifiers to .cjs...' +find dist_commonjs -type f -name '*.cjs' -exec sed "${SED_INPLACE_OPT[@]}" 's/require("\(\.[^"]*\)\.js")/require("\1.cjs")/g' {} \; + +echo 'Finished compiling' diff --git a/packages/steps/cli.sh b/packages/steps/cli.sh new file mode 100755 index 0000000000..f73fce1f21 --- /dev/null +++ b/packages/steps/cli.sh @@ -0,0 +1,7 @@ +#!/usr/bin/env bash + +set -eo pipefail + +STEPS_ROOT_DIR=$( dirname "${BASH_SOURCE[0]}" ) + +node $STEPS_ROOT_DIR/dist_commonjs/cli/cli.cjs $@ | yarn run bunyan -o short diff --git a/packages/steps/examples/functions/config.yml b/packages/steps/examples/functions/config.yml new file mode 100644 index 0000000000..72523a2e4c --- /dev/null +++ b/packages/steps/examples/functions/config.yml @@ -0,0 +1,41 @@ +build: + name: Functions + steps: + - say_hi_brent + - say_hi: + inputs: + name: Dominik + - say_hi: + inputs: + name: Szymon + - say_hi: + inputs: + greeting: Hello + - random: + id: random_number + - run: + name: Print random number + inputs: + random_number: ${ steps.random_number.value } + command: | + echo "Random number: ${ inputs.random_number }" + +functions: + say_hi: + name: Say HI + inputs: + - name: name + default_value: Brent + - name: greeting + default_value: Hi + allowed_values: [Hi, Hello] + command: echo "${ inputs.greeting }, ${ inputs.name }!" + say_hi_brent: + name: Say HI + command: echo "Hi, Brent!" + supported_platforms: [darwin, linux] + random: + name: Generate random number + outputs: + - value + command: set-output value 6 diff --git a/packages/steps/examples/functions/project/.gitkeep b/packages/steps/examples/functions/project/.gitkeep new file mode 100644 index 0000000000..e69de29bb2 diff --git a/packages/steps/examples/imports/config.yml b/packages/steps/examples/imports/config.yml new file mode 100644 index 0000000000..8ad30e74d4 --- /dev/null +++ b/packages/steps/examples/imports/config.yml @@ -0,0 +1,10 @@ +import: + - functions1.yml + - functions3.yml + +build: + name: Imports + steps: + - say_hi + - say_hello + - say_good_morning diff --git a/packages/steps/examples/imports/functions1.yml b/packages/steps/examples/imports/functions1.yml new file mode 100644 index 0000000000..5fc03bd777 --- /dev/null +++ b/packages/steps/examples/imports/functions1.yml @@ -0,0 +1,5 @@ +import: [functions2.yml] + +functions: + say_hi: + command: echo "Hi!" diff --git a/packages/steps/examples/imports/functions2.yml b/packages/steps/examples/imports/functions2.yml new file mode 100644 index 0000000000..724938575b --- /dev/null +++ b/packages/steps/examples/imports/functions2.yml @@ -0,0 +1,3 @@ +functions: + say_hello: + command: echo "Hello!" diff --git a/packages/steps/examples/imports/functions3.yml b/packages/steps/examples/imports/functions3.yml new file mode 100644 index 0000000000..d7fe9b1b31 --- /dev/null +++ b/packages/steps/examples/imports/functions3.yml @@ -0,0 +1,3 @@ +functions: + say_good_morning: + command: echo "Good morning!" diff --git a/packages/steps/examples/imports/project/.gitkeep b/packages/steps/examples/imports/project/.gitkeep new file mode 100644 index 0000000000..e69de29bb2 diff --git a/packages/steps/examples/inputs-and-outputs/config.yml b/packages/steps/examples/inputs-and-outputs/config.yml new file mode 100644 index 0000000000..d11ab83431 --- /dev/null +++ b/packages/steps/examples/inputs-and-outputs/config.yml @@ -0,0 +1,43 @@ +build: + name: Inputs, outputs and envs + steps: + - run: + name: Say HI + inputs: + name: Dominik Sokal + command: echo "Hi, ${ inputs.name }!" + - run: + name: Produce output + id: id123 + outputs: [foo] + command: | + echo "Producing output for another step" + set-output foo bar + - run: + name: Produce another output + id: id456 + outputs: + - required_param + - name: optional_param + required: false + command: | + echo "Producing more output" + set-output required_param "abc 123 456" + - run: + name: Set env variable + command: | + echo "Setting env variable EXAMPLE_VALUE=123" + set-env EXAMPLE_VALUE "123 + + test" + - run: + name: Use output from another step + inputs: + foo: ${ steps.id123.foo } + bar: ${ steps.id456.required_param } + baz: ${ steps.id456.optional_param } + command: | + echo "foo = \"${ inputs.foo }\"" + echo "bar = \"${ inputs.bar }\"" + echo "baz = \"${ inputs.baz }\"" + echo "env EXAMPLE_VALUE=$EXAMPLE_VALUE" diff --git a/packages/steps/examples/inputs-and-outputs/project/.gitkeep b/packages/steps/examples/inputs-and-outputs/project/.gitkeep new file mode 100644 index 0000000000..e69de29bb2 diff --git a/packages/steps/examples/simple/config.yml b/packages/steps/examples/simple/config.yml new file mode 100644 index 0000000000..85b0a1f7bb --- /dev/null +++ b/packages/steps/examples/simple/config.yml @@ -0,0 +1,36 @@ +build: + name: Foobar + steps: + - run: echo "Hi!" + - run: + name: Say HELLO + command: | + echo "H" + echo "E" + echo "L" + echo "L" + echo "O" + - run: + name: List files + command: ls -la + - run: + name: List files in another directory + working_directory: foo + command: ls -la + - run: + name: Steps use bash by default + command: | + echo "Steps use bash by default" + ps -p $$ + - run: + name: Steps can use another shell + shell: /bin/zsh + command: | + echo "Steps can use another shell" + ps -p $$ + - run: + name: Steps can use comments in commands + shell: /bin/zsh + command: | + # Print something + echo "Hello!" diff --git a/packages/steps/examples/simple/project/abc b/packages/steps/examples/simple/project/abc new file mode 100644 index 0000000000..01a59b011a --- /dev/null +++ b/packages/steps/examples/simple/project/abc @@ -0,0 +1 @@ +lorem ipsum diff --git a/packages/steps/examples/simple/project/def b/packages/steps/examples/simple/project/def new file mode 100644 index 0000000000..01a59b011a --- /dev/null +++ b/packages/steps/examples/simple/project/def @@ -0,0 +1 @@ +lorem ipsum diff --git a/packages/steps/examples/simple/project/foo/bar b/packages/steps/examples/simple/project/foo/bar new file mode 100644 index 0000000000..e69de29bb2 diff --git a/packages/steps/examples/simple/project/foo/baz b/packages/steps/examples/simple/project/foo/baz new file mode 100644 index 0000000000..e69de29bb2 diff --git a/packages/steps/examples/simple/project/ghi b/packages/steps/examples/simple/project/ghi new file mode 100644 index 0000000000..01a59b011a --- /dev/null +++ b/packages/steps/examples/simple/project/ghi @@ -0,0 +1 @@ +lorem ipsum diff --git a/packages/steps/jest.config.cjs b/packages/steps/jest.config.cjs new file mode 100644 index 0000000000..65626757ef --- /dev/null +++ b/packages/steps/jest.config.cjs @@ -0,0 +1,23 @@ +module.exports = { + preset: 'ts-jest/presets/default-esm', + transform: { + '^.+\\.tsx?$': [ + 'ts-jest', + { + tsconfig: 'tsconfig.json', + useESM: true, + }, + ], + }, + testEnvironment: 'node', + rootDir: 'src', + testMatch: ['**/__tests__/*-test.ts'], + coverageReporters: ['json', 'lcov'], + coverageDirectory: '../coverage/tests/', + collectCoverageFrom: ['**/*.ts'], + moduleNameMapper: { + '^(\\.\\.?/.*)\\.js$': ['$1.ts', '$0'], + }, + clearMocks: true, + setupFilesAfterEnv: ['/../jest/setup-tests.ts'], +}; diff --git a/packages/steps/jest/setup-tests.ts b/packages/steps/jest/setup-tests.ts new file mode 100644 index 0000000000..e367ec79ab --- /dev/null +++ b/packages/steps/jest/setup-tests.ts @@ -0,0 +1,5 @@ +if (process.env.NODE_ENV !== 'test') { + throw new Error("NODE_ENV environment variable must be set to 'test'."); +} + +// Always mock: diff --git a/packages/steps/package.json b/packages/steps/package.json new file mode 100644 index 0000000000..0a291118e6 --- /dev/null +++ b/packages/steps/package.json @@ -0,0 +1,73 @@ +{ + "name": "@expo/steps", + "repository": { + "type": "git", + "url": "https://github.com/expo/eas-cli.git", + "directory": "packages/steps" + }, + "type": "module", + "version": "1.0.260", + "main": "./dist_commonjs/index.cjs", + "types": "./dist_esm/index.d.ts", + "exports": { + ".": { + "types": "./dist_esm/index.d.ts", + "import": "./dist_esm/index.js", + "require": "./dist_commonjs/index.cjs" + } + }, + "files": [ + "bin", + "dist_commonjs", + "dist_esm", + "README.md" + ], + "scripts": { + "start": "yarn watch", + "build": "./build.sh", + "prepack": "./build.sh", + "watch": "chokidar --initial \"src/**/*.ts\" -i \"src/**/__tests__/**/*\" -c \"./build.sh\"", + "test": "node --experimental-vm-modules --no-warnings node_modules/.bin/jest -c=jest.config.cjs --no-cache", + "test:coverage": "node --experimental-vm-modules --no-warnings node_modules/.bin/jest -c=jest.config.cjs --no-cache --coverage", + "test:watch": "yarn test --watch", + "clean": "rm -rf node_modules dist_* coverage" + }, + "author": "Expo ", + "bugs": "https://github.com/expo/eas-cli/issues", + "license": "BUSL-1.1", + "devDependencies": { + "@jest/globals": "^29.7.0", + "@types/jest": "^29.5.12", + "@types/lodash.clonedeep": "4.5.9", + "@types/lodash.get": "4.4.9", + "@types/node": "20.14.2", + "chokidar-cli": "^3.0.0", + "eslint-plugin-async-protect": "^3.1.0", + "jest": "^29.7.0", + "ts-jest": "^29.1.4", + "ts-mockito": "^2.6.1", + "ts-node": "^10.9.2", + "typescript": "^5.5.4" + }, + "engines": { + "node": ">=18" + }, + "dependencies": { + "@expo/eas-build-job": "1.0.260", + "@expo/logger": "1.0.260", + "@expo/spawn-async": "^1.7.2", + "arg": "^5.0.2", + "fs-extra": "^11.2.0", + "joi": "^17.13.1", + "jsep": "^1.3.8", + "lodash.clonedeep": "^4.5.0", + "lodash.get": "^4.4.2", + "this-file": "^2.0.3", + "uuid": "^9.0.1", + "yaml": "^2.4.3" + }, + "volta": { + "node": "20.14.0", + "yarn": "1.22.21" + } +} diff --git a/packages/steps/src/AbstractConfigParser.ts b/packages/steps/src/AbstractConfigParser.ts new file mode 100644 index 0000000000..f31d8ddec0 --- /dev/null +++ b/packages/steps/src/AbstractConfigParser.ts @@ -0,0 +1,92 @@ +import { BuildFunction, BuildFunctionById } from './BuildFunction.js'; +import { BuildFunctionGroup } from './BuildFunctionGroup.js'; +import { BuildStep } from './BuildStep.js'; +import { BuildStepGlobalContext } from './BuildStepContext.js'; +import { BuildWorkflow } from './BuildWorkflow.js'; +import { BuildWorkflowValidator } from './BuildWorkflowValidator.js'; +import { BuildConfigError } from './errors.js'; +import { duplicates } from './utils/expodash/duplicates.js'; +import { uniq } from './utils/expodash/uniq.js'; + +export abstract class AbstractConfigParser { + protected readonly externalFunctions?: BuildFunction[]; + protected readonly externalFunctionGroups?: BuildFunctionGroup[]; + + constructor( + protected readonly ctx: BuildStepGlobalContext, + { + externalFunctions, + externalFunctionGroups, + }: { + externalFunctions?: BuildFunction[]; + externalFunctionGroups?: BuildFunctionGroup[]; + } + ) { + this.validateExternalFunctions(externalFunctions); + this.validateExternalFunctionGroups(externalFunctionGroups); + + this.externalFunctions = externalFunctions; + this.externalFunctionGroups = externalFunctionGroups; + } + + public async parseAsync(): Promise { + const { buildSteps, buildFunctionById } = + await this.parseConfigToBuildStepsAndBuildFunctionByIdMappingAsync(); + const workflow = new BuildWorkflow(this.ctx, { buildSteps, buildFunctions: buildFunctionById }); + await new BuildWorkflowValidator(workflow).validateAsync(); + return workflow; + } + + protected abstract parseConfigToBuildStepsAndBuildFunctionByIdMappingAsync(): Promise<{ + buildSteps: BuildStep[]; + buildFunctionById: BuildFunctionById; + }>; + + private validateExternalFunctions(externalFunctions?: BuildFunction[]): void { + if (externalFunctions === undefined) { + return; + } + const externalFunctionIds = externalFunctions.map((f) => f.getFullId()); + const duplicatedExternalFunctionIds = duplicates(externalFunctionIds); + if (duplicatedExternalFunctionIds.length === 0) { + return; + } + throw new BuildConfigError( + `Provided external functions with duplicated IDs: ${duplicatedExternalFunctionIds + .map((id) => `"${id}"`) + .join(', ')}` + ); + } + + private validateExternalFunctionGroups(externalFunctionGroups?: BuildFunctionGroup[]): void { + if (externalFunctionGroups === undefined) { + return; + } + const externalFunctionGroupIds = externalFunctionGroups.map((f) => f.getFullId()); + const duplicatedExternalFunctionGroupIds = duplicates(externalFunctionGroupIds); + if (duplicatedExternalFunctionGroupIds.length === 0) { + return; + } + throw new BuildConfigError( + `Provided external function groups with duplicated IDs: ${duplicatedExternalFunctionGroupIds + .map((id) => `"${id}"`) + .join(', ')}` + ); + } + + protected getExternalFunctionFullIds(): string[] { + if (this.externalFunctions === undefined) { + return []; + } + const ids = this.externalFunctions.map((f) => f.getFullId()); + return uniq(ids); + } + + protected getExternalFunctionGroupFullIds(): string[] { + if (this.externalFunctionGroups === undefined) { + return []; + } + const ids = this.externalFunctionGroups.map((f) => f.getFullId()); + return uniq(ids); + } +} diff --git a/packages/steps/src/BuildConfig.ts b/packages/steps/src/BuildConfig.ts new file mode 100644 index 0000000000..8e98275b47 --- /dev/null +++ b/packages/steps/src/BuildConfig.ts @@ -0,0 +1,468 @@ +import assert from 'assert'; +import fs from 'fs/promises'; +import path from 'path'; + +import Joi from 'joi'; +import YAML from 'yaml'; + +import { BuildConfigError, BuildWorkflowError } from './errors.js'; +import { BuildRuntimePlatform } from './BuildRuntimePlatform.js'; +import { BuildStepInputValueTypeName, BuildStepInputValueType } from './BuildStepInput.js'; +import { BuildStepEnv } from './BuildStepEnv.js'; +import { BUILD_STEP_OR_BUILD_GLOBAL_CONTEXT_REFERENCE_REGEX } from './utils/template.js'; + +export type BuildFunctions = Record; + +interface BuildFunctionsConfigFile { + configFilesToImport?: string[]; + functions?: BuildFunctions; +} + +export interface BuildConfig extends BuildFunctionsConfigFile { + build: { + name?: string; + steps: BuildStepConfig[]; + }; +} + +export type BuildStepConfig = + | BuildStepCommandRun + | BuildStepBareCommandRun + | BuildStepFunctionCall + | BuildStepBareFunctionOrFunctionGroupCall; + +export type BuildStepCommandRun = { + run: BuildFunctionCallConfig & { + outputs?: BuildStepOutputs; + command: string; + }; +}; +export type BuildStepBareCommandRun = { run: string }; +export type BuildStepFunctionCall = { + [functionId: string]: BuildFunctionCallConfig; +}; +export type BuildStepBareFunctionOrFunctionGroupCall = string; + +export type BuildFunctionCallConfig = { + id?: string; + inputs?: BuildStepInputs; + name?: string; + workingDirectory?: string; + shell?: string; + env?: BuildStepEnv; + if?: string; + timeout_minutes?: number; +}; + +export type BuildStepInputs = Record; +export type BuildStepOutputs = ( + | string + | { + name: string; + required?: boolean; + } +)[]; + +export interface BuildFunctionConfig { + inputs?: BuildFunctionInputs; + outputs?: BuildFunctionOutputs; + name?: string; + supportedRuntimePlatforms?: BuildRuntimePlatform[]; + shell?: string; + command?: string; + path?: string; +} + +export type BuildFunctionInputs = ( + | string + | { + name: string; + defaultValue?: BuildStepInputValueType; + allowedValues?: BuildStepInputValueType[]; + required?: boolean; + allowedValueType: BuildStepInputValueTypeName; + } +)[]; +export type BuildFunctionOutputs = BuildStepOutputs; + +const BuildFunctionInputsSchema = Joi.array().items( + Joi.alternatives().conditional(Joi.ref('.'), { + is: Joi.string(), + then: Joi.string().required(), + otherwise: Joi.object({ + name: Joi.string().required(), + defaultValue: Joi.when('allowedValues', { + is: Joi.exist(), + then: Joi.valid(Joi.in('allowedValues')).messages({ + 'any.only': '{{#label}} must be one of allowed values', + }), + }) + .when('allowedValueType', { + is: BuildStepInputValueTypeName.STRING, + then: Joi.string().allow(''), + }) + .when('allowedValueType', { + is: BuildStepInputValueTypeName.BOOLEAN, + then: Joi.alternatives( + Joi.boolean(), + Joi.string().pattern( + BUILD_STEP_OR_BUILD_GLOBAL_CONTEXT_REFERENCE_REGEX, + 'context or output reference regex pattern' + ) + ).messages({ + 'alternatives.types': + '{{#label}} must be a boolean or reference to output or context value', + }), + }) + .when('allowedValueType', { + is: BuildStepInputValueTypeName.NUMBER, + then: Joi.alternatives( + Joi.number(), + Joi.string().pattern( + BUILD_STEP_OR_BUILD_GLOBAL_CONTEXT_REFERENCE_REGEX, + 'context or output reference regex pattern' + ) + ).messages({ + 'alternatives.types': + '{{#label}} must be a number or reference to output or context value', + }), + }) + .when('allowedValueType', { + is: BuildStepInputValueTypeName.JSON, + then: Joi.alternatives( + Joi.object(), + Joi.string().pattern( + BUILD_STEP_OR_BUILD_GLOBAL_CONTEXT_REFERENCE_REGEX, + 'context or output reference regex pattern' + ) + ).messages({ + 'alternatives.types': + '{{#label}} must be a object or reference to output or context value', + }), + }), + allowedValues: Joi.when('allowedValueType', { + is: BuildStepInputValueTypeName.STRING, + then: Joi.array().items(Joi.string().allow('')), + }) + .when('allowedValueType', { + is: BuildStepInputValueTypeName.BOOLEAN, + then: Joi.array().items(Joi.boolean()), + }) + .when('allowedValueType', { + is: BuildStepInputValueTypeName.NUMBER, + then: Joi.array().items(Joi.number()), + }) + .when('allowedValueType', { + is: BuildStepInputValueTypeName.JSON, + then: Joi.array().items(Joi.object()), + }), + allowedValueType: Joi.string() + .valid(...Object.values(BuildStepInputValueTypeName)) + .default(BuildStepInputValueTypeName.STRING), + required: Joi.boolean(), + }) + .rename('allowed_values', 'allowedValues') + .rename('default_value', 'defaultValue') + .rename('type', 'allowedValueType') + .required(), + }) +); + +const BuildStepOutputsSchema = Joi.array().items( + Joi.alternatives().try( + Joi.string().required(), + Joi.object({ + name: Joi.string().required(), + required: Joi.boolean(), + }).required() + ) +); + +const BuildFunctionCallSchema = Joi.object({ + id: Joi.string(), + inputs: Joi.object().pattern( + Joi.string(), + Joi.alternatives().try(Joi.string().allow(''), Joi.boolean(), Joi.number(), Joi.object()) + ), + name: Joi.string(), + workingDirectory: Joi.string(), + shell: Joi.string(), + env: Joi.object().pattern(Joi.string(), Joi.string().allow('')), + if: Joi.string(), + timeout_minutes: Joi.number().positive(), +}).rename('working_directory', 'workingDirectory'); + +const BuildStepConfigSchema = Joi.any() + .invalid(null) + .when( + Joi.object().pattern( + Joi.string().disallow('run').required(), + Joi.object().unknown().required() + ), + { + then: Joi.object().pattern( + Joi.string().disallow('run').min(1).required(), + BuildFunctionCallSchema.required(), + { matches: Joi.array().length(1) } + ), + } + ) + .when(Joi.object({ run: Joi.object().unknown().required() }), { + then: Joi.object({ + run: BuildFunctionCallSchema.keys({ + outputs: BuildStepOutputsSchema, + command: Joi.string().required(), + }), + }), + }) + .when(Joi.object({ run: Joi.string().required() }), { + then: Joi.object({ + run: Joi.string().min(1).required(), + }), + }) + .when(Joi.string(), { + then: Joi.string().min(1), + }); + +const BuildFunctionConfigSchema = Joi.object({ + name: Joi.string(), + supportedRuntimePlatforms: Joi.array().items(...Object.values(BuildRuntimePlatform)), + inputs: BuildFunctionInputsSchema, + outputs: BuildStepOutputsSchema, + command: Joi.string(), + path: Joi.string(), + shell: Joi.string(), +}) + .rename('supported_platforms', 'supportedRuntimePlatforms') + .xor('command', 'path') + .nand('command', 'path'); + +export const BuildFunctionsConfigFileSchema = Joi.object({ + configFilesToImport: Joi.array().items(Joi.string().pattern(/\.y(a)?ml$/)), + functions: Joi.object().pattern( + Joi.string() + .pattern(/^[\w-]+$/, 'function names') + .min(1) + .required() + .disallow('run'), + BuildFunctionConfigSchema.required() + ), +}) + .rename('import', 'configFilesToImport') + .required(); + +export const BuildConfigSchema = BuildFunctionsConfigFileSchema.append({ + build: Joi.object({ + name: Joi.string(), + steps: Joi.array().items(BuildStepConfigSchema.required()).required(), + }).required(), +}).required(); + +interface BuildConfigValidationParams { + externalFunctionIds?: string[]; + externalFunctionGroupsIds?: string[]; + skipNamespacedFunctionsOrFunctionGroupsCheck?: boolean; +} + +export async function readAndValidateBuildConfigFromPathAsync( + configPath: string, + params: BuildConfigValidationParams = {} +): Promise { + const rawConfig = await readRawBuildConfigAsync(configPath); + + const config = validateConfig(BuildConfigSchema, rawConfig); + for (const functionName in config.functions) { + const customFunctionPath = config.functions[functionName].path; + if (customFunctionPath) { + config.functions[functionName].path = maybeResolveCustomFunctionRelativePath( + path.dirname(configPath), + customFunctionPath + ); + } + } + + const importedFunctions = await importFunctionsAsync(configPath, config.configFilesToImport); + mergeConfigWithImportedFunctions(config, importedFunctions); + validateAllFunctionsExist(config, params); + return config; +} + +async function importFunctionsAsync( + baseConfigPath: string, + configPathsToImport?: string[] +): Promise { + if (!configPathsToImport) { + return {}; + } + + const baseConfigDir = path.dirname(baseConfigPath); + + const errors: BuildConfigError[] = []; + const importedFunctions: BuildFunctions = {}; + // this is a set of visited files identified by ABSOLUTE paths + const visitedFiles = new Set([baseConfigPath]); + const configFilesToVisit = (configPathsToImport ?? []).map((childConfigRelativePath) => + path.resolve(baseConfigDir, childConfigRelativePath) + ); + while (configFilesToVisit.length > 0) { + const childConfigPath = configFilesToVisit.shift(); + assert(childConfigPath, 'Guaranteed by loop condition'); + if (visitedFiles.has(childConfigPath)) { + continue; + } + visitedFiles.add(childConfigPath); + try { + const childConfig = await readAndValidateBuildFunctionsConfigFileAsync(childConfigPath); + const childDir = path.dirname(childConfigPath); + for (const functionName in childConfig.functions) { + if (!(functionName in importedFunctions)) { + const f = childConfig.functions[functionName]; + if (f.path) { + f.path = maybeResolveCustomFunctionRelativePath(childDir, f.path); + } + importedFunctions[functionName] = f; + } + } + if (childConfig.configFilesToImport) { + configFilesToVisit.push( + ...childConfig.configFilesToImport.map((relativePath) => + path.resolve(childDir, relativePath) + ) + ); + } + } catch (err) { + if (err instanceof BuildConfigError) { + errors.push(err); + } else { + throw err; + } + } + } + if (errors.length > 0) { + throw new BuildWorkflowError(`Detected build config errors in imported files.`, errors); + } + return importedFunctions; +} + +export async function readAndValidateBuildFunctionsConfigFileAsync( + configPath: string +): Promise { + const rawConfig = await readRawBuildConfigAsync(configPath); + return validateConfig(BuildFunctionsConfigFileSchema, rawConfig); +} + +export async function readRawBuildConfigAsync(configPath: string): Promise { + const contents = await fs.readFile(configPath, 'utf-8'); + return YAML.parse(contents); +} + +export function validateConfig( + schema: Joi.ObjectSchema, + config: object, + configFilePath?: string +): T { + const { error, value } = schema.validate(config, { + allowUnknown: false, + abortEarly: false, + }); + if (error) { + const errorMessage = error.details.map(({ message }) => message).join(', '); + throw new BuildConfigError(errorMessage, { + cause: error, + ...(configFilePath && { metadata: { configFilePath } }), + }); + } + return value; +} + +export function mergeConfigWithImportedFunctions( + config: BuildConfig, + importedFunctions: BuildFunctions +): void { + if (Object.keys(importedFunctions).length === 0) { + return; + } + config.functions = config.functions ?? {}; + for (const functionName in importedFunctions) { + if (!(functionName in config.functions)) { + config.functions[functionName] = importedFunctions[functionName]; + } + } +} + +export function isBuildStepCommandRun(step: BuildStepConfig): step is BuildStepCommandRun { + return Boolean(step) && typeof step === 'object' && typeof step.run === 'object'; +} + +export function isBuildStepBareCommandRun(step: BuildStepConfig): step is BuildStepBareCommandRun { + return Boolean(step) && typeof step === 'object' && typeof step.run === 'string'; +} + +export function isBuildStepFunctionCall(step: BuildStepConfig): step is BuildStepFunctionCall { + return Boolean(step) && typeof step === 'object' && !('run' in step); +} + +export function isBuildStepBareFunctionOrFunctionGroupCall( + step: BuildStepConfig +): step is BuildStepBareFunctionOrFunctionGroupCall { + return typeof step === 'string'; +} + +export function validateAllFunctionsExist( + config: BuildConfig, + { + externalFunctionIds = [], + externalFunctionGroupsIds = [], + skipNamespacedFunctionsOrFunctionGroupsCheck, + }: BuildConfigValidationParams +): void { + const calledFunctionsOrFunctionGroupsSet = new Set(); + for (const step of config.build.steps) { + if (typeof step === 'string') { + calledFunctionsOrFunctionGroupsSet.add(step); + } else if (step !== null && !('run' in step)) { + const keys = Object.keys(step); + assert( + keys.length === 1, + 'There must be at most one function call in the step (enforced by joi).' + ); + calledFunctionsOrFunctionGroupsSet.add(keys[0]); + } + } + const calledFunctionsOrFunctionGroup = Array.from(calledFunctionsOrFunctionGroupsSet); + const externalFunctionIdsSet = new Set(externalFunctionIds); + const externalFunctionGroupsIdsSet = new Set(externalFunctionGroupsIds); + const nonExistentFunctionsOrFunctionGroups = calledFunctionsOrFunctionGroup.filter( + (calledFunctionOrFunctionGroup) => { + if ( + isFullIdNamespaced(calledFunctionOrFunctionGroup) && + skipNamespacedFunctionsOrFunctionGroupsCheck + ) { + return false; + } + return ( + !(calledFunctionOrFunctionGroup in (config.functions ?? {})) && + !externalFunctionIdsSet.has(calledFunctionOrFunctionGroup) && + !externalFunctionGroupsIdsSet.has(calledFunctionOrFunctionGroup) + ); + } + ); + if (nonExistentFunctionsOrFunctionGroups.length > 0) { + throw new BuildConfigError( + `Calling non-existent functions: ${nonExistentFunctionsOrFunctionGroups + .map((f) => `"${f}"`) + .join(', ')}.` + ); + } +} + +function maybeResolveCustomFunctionRelativePath(dir: string, customFunctionPath: string): string { + if (!path.isAbsolute(customFunctionPath)) { + return path.resolve(dir, customFunctionPath); + } + return customFunctionPath; +} + +function isFullIdNamespaced(fullId: string): boolean { + return fullId.includes('/'); +} diff --git a/packages/steps/src/BuildConfigParser.ts b/packages/steps/src/BuildConfigParser.ts new file mode 100644 index 0000000000..3cffd6c1fd --- /dev/null +++ b/packages/steps/src/BuildConfigParser.ts @@ -0,0 +1,383 @@ +import assert from 'assert'; + +import { + BuildConfig, + BuildFunctionConfig, + BuildFunctionInputs, + BuildFunctionOutputs, + BuildStepBareCommandRun, + BuildStepBareFunctionOrFunctionGroupCall, + BuildStepCommandRun, + BuildStepConfig, + BuildStepFunctionCall, + BuildStepInputs, + BuildStepOutputs, + isBuildStepBareCommandRun, + isBuildStepBareFunctionOrFunctionGroupCall, + isBuildStepCommandRun, + readAndValidateBuildConfigFromPathAsync, +} from './BuildConfig.js'; +import { BuildFunction, BuildFunctionById } from './BuildFunction.js'; +import { BuildStep } from './BuildStep.js'; +import { + BuildStepInput, + BuildStepInputProvider, + BuildStepInputValueTypeName, +} from './BuildStepInput.js'; +import { BuildStepGlobalContext } from './BuildStepContext.js'; +import { BuildStepOutput, BuildStepOutputProvider } from './BuildStepOutput.js'; +import { BuildConfigError } from './errors.js'; +import { + BuildFunctionGroup, + BuildFunctionGroupById, + createBuildFunctionGroupByIdMapping, +} from './BuildFunctionGroup.js'; +import { AbstractConfigParser } from './AbstractConfigParser.js'; + +export class BuildConfigParser extends AbstractConfigParser { + private readonly configPath: string; + constructor( + ctx: BuildStepGlobalContext, + { + configPath, + externalFunctions, + externalFunctionGroups, + }: { + configPath: string; + externalFunctions?: BuildFunction[]; + externalFunctionGroups?: BuildFunctionGroup[]; + } + ) { + super(ctx, { + externalFunctions, + externalFunctionGroups, + }); + + this.configPath = configPath; + } + + protected async parseConfigToBuildStepsAndBuildFunctionByIdMappingAsync(): Promise<{ + buildSteps: BuildStep[]; + buildFunctionById: BuildFunctionById; + }> { + const config = await readAndValidateBuildConfigFromPathAsync(this.configPath, { + externalFunctionIds: this.getExternalFunctionFullIds(), + externalFunctionGroupsIds: this.getExternalFunctionGroupFullIds(), + }); + const configBuildFunctions = this.createBuildFunctionsFromConfig(config.functions); + const buildFunctions = this.mergeBuildFunctionsWithExternal( + configBuildFunctions, + this.externalFunctions + ); + const buildFunctionGroups = createBuildFunctionGroupByIdMapping( + this.externalFunctionGroups ?? [] + ); + const buildSteps: BuildStep[] = []; + for (const stepConfig of config.build.steps) { + buildSteps.push( + ...this.createBuildStepFromConfig(stepConfig, buildFunctions, buildFunctionGroups) + ); + } + return { + buildSteps, + buildFunctionById: buildFunctions, + }; + } + + private createBuildStepFromConfig( + buildStepConfig: BuildStepConfig, + buildFunctions: BuildFunctionById, + buildFunctionGroups: BuildFunctionGroupById + ): BuildStep[] { + if (isBuildStepCommandRun(buildStepConfig)) { + return [this.createBuildStepFromBuildStepCommandRun(buildStepConfig)]; + } else if (isBuildStepBareCommandRun(buildStepConfig)) { + return [this.createBuildStepFromBuildStepBareCommandRun(buildStepConfig)]; + } else if (isBuildStepBareFunctionOrFunctionGroupCall(buildStepConfig)) { + return this.createBuildStepsFromBareBuildStepFunctionOrBareBuildStepFunctionGroupCall( + buildFunctions, + buildFunctionGroups, + buildStepConfig + ); + } else if (buildStepConfig !== null) { + return this.createBuildStepsFromBuildStepFunctionOrBuildStepFunctionGroupCall( + buildFunctions, + buildFunctionGroups, + buildStepConfig + ); + } else { + throw new BuildConfigError( + 'Invalid build step configuration detected. Build step cannot be empty.' + ); + } + } + + private createBuildStepFromBuildStepCommandRun({ run }: BuildStepCommandRun): BuildStep { + const { + id: maybeId, + inputs: inputsConfig, + outputs: outputsConfig, + name, + workingDirectory, + shell, + command, + env, + if: ifCondition, + timeout_minutes, + } = run; + const id = BuildStep.getNewId(maybeId); + const displayName = BuildStep.getDisplayName({ id, name, command }); + const inputs = + inputsConfig && this.createBuildStepInputsFromDefinition(inputsConfig, displayName); + const outputs = + outputsConfig && this.createBuildStepOutputsFromDefinition(outputsConfig, displayName); + const timeoutMs = timeout_minutes !== undefined ? timeout_minutes * 60 * 1000 : undefined; + return new BuildStep(this.ctx, { + id, + inputs, + outputs, + name, + displayName, + workingDirectory, + shell, + command, + env, + ifCondition, + timeoutMs, + }); + } + + private createBuildStepFromBuildStepBareCommandRun({ + run: command, + }: BuildStepBareCommandRun): BuildStep { + const id = BuildStep.getNewId(); + const displayName = BuildStep.getDisplayName({ id, command }); + return new BuildStep(this.ctx, { + id, + displayName, + command, + }); + } + + private createBuildStepsFromBuildStepFunctionGroupCall( + buildFunctionGroups: BuildFunctionGroupById, + buildStepFunctionCall: BuildStepFunctionCall + ): BuildStep[] { + const functionId = getFunctionIdFromBuildStepFunctionCall(buildStepFunctionCall); + const buildFunctionGroup = buildFunctionGroups[functionId]; + assert(buildFunctionGroup, `Build function group with id "${functionId}" is not defined.`); + return buildFunctionGroup.createBuildStepsFromFunctionGroupCall(this.ctx, { + callInputs: buildStepFunctionCall[functionId].inputs, + }); + } + + private createBuildStepsFromBuildStepBareFunctionGroupCall( + buildFunctionGroups: BuildFunctionGroupById, + functionGroupId: string + ): BuildStep[] { + const buildFunctionGroup = buildFunctionGroups[functionGroupId]; + assert(buildFunctionGroup, `Build function group with id "${functionGroupId}" is not defined.`); + return buildFunctionGroup.createBuildStepsFromFunctionGroupCall(this.ctx); + } + + private createBuildStepFromBuildStepBareFunctionCall( + buildFunctions: BuildFunctionById, + functionId: BuildStepBareFunctionOrFunctionGroupCall + ): BuildStep { + const buildFunction = buildFunctions[functionId]; + return buildFunction.createBuildStepFromFunctionCall(this.ctx); + } + + private createBuildStepsFromBareBuildStepFunctionOrBareBuildStepFunctionGroupCall( + buildFunctions: BuildFunctionById, + buildFunctionGroups: BuildFunctionGroupById, + functionOrFunctionGroupId: string + ): BuildStep[] { + const maybeFunctionGroup = buildFunctionGroups[functionOrFunctionGroupId]; + if (maybeFunctionGroup) { + return this.createBuildStepsFromBuildStepBareFunctionGroupCall( + buildFunctionGroups, + functionOrFunctionGroupId + ); + } + return [ + this.createBuildStepFromBuildStepBareFunctionCall(buildFunctions, functionOrFunctionGroupId), + ]; + } + + private createBuildStepsFromBuildStepFunctionOrBuildStepFunctionGroupCall( + buildFunctions: BuildFunctionById, + buildFunctionGroups: BuildFunctionGroupById, + buildStepFunctionCall: BuildStepFunctionCall + ): BuildStep[] { + const functionId = getFunctionIdFromBuildStepFunctionCall(buildStepFunctionCall); + + const maybeFunctionGroup = buildFunctionGroups[functionId]; + if (maybeFunctionGroup) { + return this.createBuildStepsFromBuildStepFunctionGroupCall( + buildFunctionGroups, + buildStepFunctionCall + ); + } + return [this.createBuildStepFromBuildStepFunctionCall(buildFunctions, buildStepFunctionCall)]; + } + + private createBuildStepFromBuildStepFunctionCall( + buildFunctions: BuildFunctionById, + buildStepFunctionCall: BuildStepFunctionCall + ): BuildStep { + const functionId = getFunctionIdFromBuildStepFunctionCall(buildStepFunctionCall); + const buildFunctionCallConfig = buildStepFunctionCall[functionId]; + const buildFunction = buildFunctions[functionId]; + const timeoutMs = + buildFunctionCallConfig.timeout_minutes !== undefined + ? buildFunctionCallConfig.timeout_minutes * 60 * 1000 + : undefined; + return buildFunction.createBuildStepFromFunctionCall(this.ctx, { + id: buildFunctionCallConfig.id, + name: buildFunctionCallConfig.name, + callInputs: buildFunctionCallConfig.inputs, + workingDirectory: buildFunctionCallConfig.workingDirectory, + shell: buildFunctionCallConfig.shell, + env: buildFunctionCallConfig.env, + ifCondition: buildFunctionCallConfig.if, + timeoutMs, + }); + } + + private createBuildFunctionsFromConfig( + buildFunctionsConfig: BuildConfig['functions'] + ): BuildFunctionById { + if (!buildFunctionsConfig) { + return {}; + } + const result: BuildFunctionById = {}; + for (const [functionId, buildFunctionConfig] of Object.entries(buildFunctionsConfig)) { + const buildFunction = this.createBuildFunctionFromConfig({ + id: functionId, + ...buildFunctionConfig, + }); + result[buildFunction.getFullId()] = buildFunction; + } + return result; + } + + private createBuildFunctionFromConfig({ + id, + name, + inputs: inputsConfig, + outputs: outputsConfig, + shell, + command, + supportedRuntimePlatforms, + path: customFunctionModulePath, + }: BuildFunctionConfig & { id: string }): BuildFunction { + const inputProviders = + inputsConfig && this.createBuildStepInputProvidersFromBuildFunctionInputs(inputsConfig); + const outputProviders = + outputsConfig && this.createBuildStepOutputProvidersFromBuildFunctionOutputs(outputsConfig); + return new BuildFunction({ + id, + name, + inputProviders, + outputProviders, + shell, + command, + customFunctionModulePath, + supportedRuntimePlatforms, + }); + } + + private createBuildStepInputsFromDefinition( + buildStepInputs: BuildStepInputs, + stepDisplayName: string + ): BuildStepInput[] { + return Object.entries(buildStepInputs).map( + ([key, value]) => + new BuildStepInput(this.ctx, { + id: key, + stepDisplayName, + defaultValue: value, + required: true, + allowedValueTypeName: + typeof value === 'object' + ? BuildStepInputValueTypeName.JSON + : (typeof value as BuildStepInputValueTypeName), + }) + ); + } + + private createBuildStepInputProvidersFromBuildFunctionInputs( + buildFunctionInputs: BuildFunctionInputs + ): BuildStepInputProvider[] { + return buildFunctionInputs.map((entry) => { + return typeof entry === 'string' + ? BuildStepInput.createProvider({ + id: entry, + required: true, + allowedValueTypeName: BuildStepInputValueTypeName.STRING, + }) + : BuildStepInput.createProvider({ + id: entry.name, + required: entry.required ?? true, + defaultValue: entry.defaultValue, + allowedValues: entry.allowedValues, + allowedValueTypeName: entry.allowedValueType, + }); + }); + } + + private createBuildStepOutputsFromDefinition( + buildStepOutputs: BuildStepOutputs, + stepDisplayName: string + ): BuildStepOutput[] { + return buildStepOutputs.map((entry) => + typeof entry === 'string' + ? new BuildStepOutput(this.ctx, { id: entry, stepDisplayName, required: true }) + : new BuildStepOutput(this.ctx, { + id: entry.name, + stepDisplayName, + required: entry.required ?? true, + }) + ); + } + + private createBuildStepOutputProvidersFromBuildFunctionOutputs( + buildFunctionOutputs: BuildFunctionOutputs + ): BuildStepOutputProvider[] { + return buildFunctionOutputs.map((entry) => + typeof entry === 'string' + ? BuildStepOutput.createProvider({ id: entry, required: true }) + : BuildStepOutput.createProvider({ id: entry.name, required: entry.required ?? true }) + ); + } + + private mergeBuildFunctionsWithExternal( + configFunctions: BuildFunctionById, + externalFunctions?: BuildFunction[] + ): BuildFunctionById { + const result: BuildFunctionById = { ...configFunctions }; + if (externalFunctions === undefined) { + return result; + } + for (const buildFunction of externalFunctions) { + // functions defined in config shadow the external ones + const fullId = buildFunction.getFullId(); + if (!(fullId in result)) { + result[fullId] = buildFunction; + } + } + return result; + } +} + +function getFunctionIdFromBuildStepFunctionCall( + buildStepFunctionCall: BuildStepFunctionCall +): string { + const keys = Object.keys(buildStepFunctionCall); + assert( + keys.length === 1, + 'There must be at most one function call in the step (enforced by joi).' + ); + return keys[0]; +} diff --git a/packages/steps/src/BuildFunction.ts b/packages/steps/src/BuildFunction.ts new file mode 100644 index 0000000000..f8055284ea --- /dev/null +++ b/packages/steps/src/BuildFunction.ts @@ -0,0 +1,139 @@ +import assert from 'assert'; + +import { BuildRuntimePlatform } from './BuildRuntimePlatform.js'; +import { BuildStep, BuildStepFunction } from './BuildStep.js'; +import { BuildStepGlobalContext } from './BuildStepContext.js'; +import { BuildStepInputProvider } from './BuildStepInput.js'; +import { BuildStepOutputProvider } from './BuildStepOutput.js'; +import { BuildStepEnv } from './BuildStepEnv.js'; +import { createCustomFunctionCall } from './utils/customFunction.js'; + +export type BuildFunctionById = Record; +export type BuildFunctionCallInputs = Record; + +export class BuildFunction { + public readonly namespace?: string; + public readonly id: string; + public readonly name?: string; + public readonly supportedRuntimePlatforms?: BuildRuntimePlatform[]; + public readonly inputProviders?: BuildStepInputProvider[]; + public readonly outputProviders?: BuildStepOutputProvider[]; + public readonly command?: string; + public readonly customFunctionModulePath?: string; + public readonly fn?: BuildStepFunction; + public readonly shell?: string; + + constructor({ + namespace, + id, + name, + supportedRuntimePlatforms, + inputProviders, + outputProviders, + command, + fn, + customFunctionModulePath, + shell, + }: { + namespace?: string; + id: string; + name?: string; + supportedRuntimePlatforms?: BuildRuntimePlatform[]; + inputProviders?: BuildStepInputProvider[]; + outputProviders?: BuildStepOutputProvider[]; + command?: string; + customFunctionModulePath?: string; + fn?: BuildStepFunction; + shell?: string; + }) { + assert( + command !== undefined || fn !== undefined || customFunctionModulePath !== undefined, + 'Either command, fn or path must be defined.' + ); + + assert(!(command !== undefined && fn !== undefined), 'Command and fn cannot be both set.'); + assert( + !(command !== undefined && customFunctionModulePath !== undefined), + 'Command and path cannot be both set.' + ); + assert( + !(fn !== undefined && customFunctionModulePath !== undefined), + 'Fn and path cannot be both set.' + ); + + this.namespace = namespace; + this.id = id; + this.name = name; + this.supportedRuntimePlatforms = supportedRuntimePlatforms; + this.inputProviders = inputProviders; + this.outputProviders = outputProviders; + this.command = command; + this.fn = fn; + this.shell = shell; + this.customFunctionModulePath = customFunctionModulePath; + } + + public getFullId(): string { + return this.namespace === undefined ? this.id : `${this.namespace}/${this.id}`; + } + + public createBuildStepFromFunctionCall( + ctx: BuildStepGlobalContext, + { + id, + name, + callInputs = {}, + workingDirectory, + shell, + env, + ifCondition, + timeoutMs, + }: { + id?: string; + name?: string; + callInputs?: BuildFunctionCallInputs; + workingDirectory?: string; + shell?: string; + env?: BuildStepEnv; + ifCondition?: string; + timeoutMs?: number; + } = {} + ): BuildStep { + const buildStepId = BuildStep.getNewId(id); + const buildStepName = name ?? this.name; + const buildStepDisplayName = BuildStep.getDisplayName({ + id: buildStepId, + command: this.command, + name: buildStepName, + }); + + const inputs = this.inputProviders?.map((inputProvider) => { + const input = inputProvider(ctx, buildStepId); + if (input.id in callInputs) { + input.set(callInputs[input.id]); + } + return input; + }); + const outputs = this.outputProviders?.map((outputProvider) => outputProvider(ctx, buildStepId)); + + return new BuildStep(ctx, { + id: buildStepId, + name: buildStepName, + displayName: buildStepDisplayName, + command: this.command, + fn: + this.fn ?? + (this.customFunctionModulePath + ? createCustomFunctionCall(this.customFunctionModulePath) + : undefined), + workingDirectory, + inputs, + outputs, + shell, + supportedRuntimePlatforms: this.supportedRuntimePlatforms, + env, + ifCondition, + timeoutMs, + }); + } +} diff --git a/packages/steps/src/BuildFunctionGroup.ts b/packages/steps/src/BuildFunctionGroup.ts new file mode 100644 index 0000000000..f527c6a167 --- /dev/null +++ b/packages/steps/src/BuildFunctionGroup.ts @@ -0,0 +1,78 @@ +import { BuildFunctionCallInputs } from './BuildFunction.js'; +import { BuildStep } from './BuildStep.js'; +import { BuildStepGlobalContext } from './BuildStepContext.js'; +import { + BuildStepInputById, + BuildStepInputProvider, + makeBuildStepInputByIdMap, +} from './BuildStepInput.js'; +import { BuildConfigError } from './errors.js'; + +export type BuildFunctionGroupById = Record; + +export class BuildFunctionGroup { + public readonly namespace: string; + public readonly id: string; + public readonly inputProviders?: BuildStepInputProvider[]; + public readonly createBuildStepsFromFunctionGroupCall: ( + globalCtx: BuildStepGlobalContext, + options?: { + callInputs?: BuildFunctionCallInputs; + } + ) => BuildStep[]; + + constructor({ + namespace, + id, + inputProviders, + createBuildStepsFromFunctionGroupCall, + }: { + namespace: string; + id: string; + inputProviders?: BuildStepInputProvider[]; + createBuildStepsFromFunctionGroupCall: ( + globalCtx: BuildStepGlobalContext, + { + inputs, + }: { + inputs: BuildStepInputById; + } + ) => BuildStep[]; + }) { + this.namespace = namespace; + this.id = id; + this.inputProviders = inputProviders; + + this.createBuildStepsFromFunctionGroupCall = (ctx, { callInputs = {} } = {}) => { + const inputs = this.inputProviders?.map((inputProvider) => { + const input = inputProvider(ctx, id); + if (input.id in callInputs) { + input.set(callInputs[input.id]); + } + return input; + }); + return createBuildStepsFromFunctionGroupCall(ctx, { + inputs: makeBuildStepInputByIdMap(inputs), + }); + }; + } + + public getFullId(): string { + return this.namespace === undefined ? this.id : `${this.namespace}/${this.id}`; + } +} + +export function createBuildFunctionGroupByIdMapping( + buildFunctionGroups: BuildFunctionGroup[] +): BuildFunctionGroupById { + const buildFunctionGroupById: BuildFunctionGroupById = {}; + for (const buildFunctionGroup of buildFunctionGroups) { + if (buildFunctionGroupById[buildFunctionGroup.getFullId()] !== undefined) { + throw new BuildConfigError( + `Build function group with id ${buildFunctionGroup.getFullId()} is already defined.` + ); + } + buildFunctionGroupById[buildFunctionGroup.getFullId()] = buildFunctionGroup; + } + return buildFunctionGroupById; +} diff --git a/packages/steps/src/BuildRuntimePlatform.ts b/packages/steps/src/BuildRuntimePlatform.ts new file mode 100644 index 0000000000..3cac11a6b4 --- /dev/null +++ b/packages/steps/src/BuildRuntimePlatform.ts @@ -0,0 +1,4 @@ +export enum BuildRuntimePlatform { + DARWIN = 'darwin', + LINUX = 'linux', +} diff --git a/packages/steps/src/BuildStep.ts b/packages/steps/src/BuildStep.ts new file mode 100644 index 0000000000..f8e5f3c4ad --- /dev/null +++ b/packages/steps/src/BuildStep.ts @@ -0,0 +1,561 @@ +import assert from 'assert'; +import fs from 'fs/promises'; +import path from 'path'; +import { Buffer } from 'buffer'; + +import { v4 as uuidv4 } from 'uuid'; +import { JobInterpolationContext } from '@expo/eas-build-job'; + +import { BuildStepContext, BuildStepGlobalContext } from './BuildStepContext.js'; +import { BuildStepInput, BuildStepInputById, makeBuildStepInputByIdMap } from './BuildStepInput.js'; +import { + BuildStepOutput, + BuildStepOutputById, + SerializedBuildStepOutput, + makeBuildStepOutputByIdMap, +} from './BuildStepOutput.js'; +import { BIN_PATH } from './utils/shell/bin.js'; +import { getShellCommandAndArgs } from './utils/shell/command.js'; +import { + cleanUpStepTemporaryDirectoriesAsync, + getTemporaryEnvsDirPath, + getTemporaryOutputsDirPath, + saveScriptToTemporaryFileAsync, +} from './BuildTemporaryFiles.js'; +import { spawnAsync } from './utils/shell/spawn.js'; +import { interpolateWithInputs, interpolateWithOutputs } from './utils/template.js'; +import { BuildStepRuntimeError } from './errors.js'; +import { BuildStepEnv } from './BuildStepEnv.js'; +import { BuildRuntimePlatform } from './BuildRuntimePlatform.js'; +import { jsepEval } from './utils/jsepEval.js'; +import { interpolateJobContext } from './interpolation.js'; + +export enum BuildStepStatus { + NEW = 'new', + IN_PROGRESS = 'in-progress', + SKIPPED = 'skipped', + FAIL = 'fail', + WARNING = 'warning', + SUCCESS = 'success', +} + +export enum BuildStepLogMarker { + START_STEP = 'start-step', + END_STEP = 'end-step', +} + +export type BuildStepFunction = ( + ctx: BuildStepContext, + { + inputs, + outputs, + env, + }: { + inputs: { [key: string]: { value: unknown } }; + outputs: BuildStepOutputById; + env: BuildStepEnv; + signal?: AbortSignal; + } +) => unknown; + +// TODO: move to a place common with tests +const UUID_REGEX = + /^[0-9a-fA-F]{8}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{12}$/; + +export interface SerializedBuildStepOutputAccessor { + id: string; + executed: boolean; + outputById: Record; + displayName: string; +} + +export class BuildStepOutputAccessor { + constructor( + public readonly id: string, + public readonly displayName: string, + protected readonly executed: boolean, + protected readonly outputById: BuildStepOutputById + ) {} + + public get outputs(): BuildStepOutput[] { + return Object.values(this.outputById); + } + + public getOutputValueByName(name: string): string | undefined { + if (!this.executed) { + throw new BuildStepRuntimeError( + `Failed getting output "${name}" from step "${this.displayName}". The step has not been executed yet.` + ); + } + if (!this.hasOutputParameter(name)) { + throw new BuildStepRuntimeError(`Step "${this.displayName}" does not have output "${name}".`); + } + return this.outputById[name].value; + } + + public hasOutputParameter(name: string): boolean { + return name in this.outputById; + } + + public serialize(): SerializedBuildStepOutputAccessor { + return { + id: this.id, + executed: this.executed, + outputById: Object.fromEntries( + Object.entries(this.outputById).map(([key, value]) => [key, value.serialize()]) + ), + displayName: this.displayName, + }; + } + + public static deserialize( + serialized: SerializedBuildStepOutputAccessor + ): BuildStepOutputAccessor { + const outputById = Object.fromEntries( + Object.entries(serialized.outputById).map(([key, value]) => [ + key, + BuildStepOutput.deserialize(value), + ]) + ); + return new BuildStepOutputAccessor( + serialized.id, + serialized.displayName, + serialized.executed, + outputById + ); + } +} + +export class BuildStep extends BuildStepOutputAccessor { + public readonly id: string; + public readonly name?: string; + public readonly displayName: string; + public readonly supportedRuntimePlatforms?: BuildRuntimePlatform[]; + public readonly inputs?: BuildStepInput[]; + public readonly outputById: BuildStepOutputById; + public readonly command?: string; + public readonly fn?: BuildStepFunction; + public readonly shell: string; + public readonly ctx: BuildStepContext; + public readonly stepEnvOverrides: BuildStepEnv; + public readonly ifCondition?: string; + public readonly timeoutMs?: number; + public status: BuildStepStatus; + private readonly outputsDir: string; + private readonly envsDir: string; + + private readonly internalId: string; + private readonly inputById: BuildStepInputById; + protected executed = false; + + public static getNewId(userDefinedId?: string): string { + return userDefinedId ?? uuidv4(); + } + + public static getDisplayName({ + id, + name, + command, + }: { + id: string; + name?: string; + command?: string; + }): string { + if (name) { + return name; + } + if (!id.match(UUID_REGEX)) { + return id; + } + if (command) { + const splits = command.trim().split('\n'); + for (const split of splits) { + const trimmed = split.trim(); + if (trimmed && !trimmed.startsWith('#')) { + return trimmed; + } + } + } + return id; + } + + constructor( + ctx: BuildStepGlobalContext, + { + id, + name, + displayName, + inputs, + outputs, + command, + fn, + workingDirectory: maybeWorkingDirectory, + shell, + supportedRuntimePlatforms: maybeSupportedRuntimePlatforms, + env, + ifCondition, + timeoutMs, + }: { + id: string; + name?: string; + displayName: string; + inputs?: BuildStepInput[]; + outputs?: BuildStepOutput[]; + command?: string; + fn?: BuildStepFunction; + workingDirectory?: string; + shell?: string; + supportedRuntimePlatforms?: BuildRuntimePlatform[]; + env?: BuildStepEnv; + ifCondition?: string; + timeoutMs?: number; + } + ) { + assert(command !== undefined || fn !== undefined, 'Either command or fn must be defined.'); + assert(!(command !== undefined && fn !== undefined), 'Command and fn cannot be both set.'); + const outputById = makeBuildStepOutputByIdMap(outputs); + super(id, displayName, false, outputById); + + this.id = id; + this.name = name; + this.displayName = displayName; + this.supportedRuntimePlatforms = maybeSupportedRuntimePlatforms; + this.inputs = inputs; + this.inputById = makeBuildStepInputByIdMap(inputs); + this.outputById = outputById; + this.fn = fn; + this.command = command; + this.shell = shell ?? '/bin/bash -eo pipefail'; + this.ifCondition = ifCondition; + this.timeoutMs = timeoutMs; + this.status = BuildStepStatus.NEW; + + this.internalId = uuidv4(); + + const logger = ctx.baseLogger.child({ + buildStepInternalId: this.internalId, + buildStepId: this.id, + buildStepDisplayName: this.displayName, + }); + this.ctx = ctx.stepCtx({ logger, relativeWorkingDirectory: maybeWorkingDirectory }); + this.stepEnvOverrides = env ?? {}; + + this.outputsDir = getTemporaryOutputsDirPath(ctx, this.id); + this.envsDir = getTemporaryEnvsDirPath(ctx, this.id); + + ctx.registerStep(this); + } + + public async executeAsync(): Promise { + try { + this.ctx.logger.info( + { marker: BuildStepLogMarker.START_STEP }, + `Executing build step "${this.displayName}"` + ); + this.status = BuildStepStatus.IN_PROGRESS; + + await fs.mkdir(this.outputsDir, { recursive: true }); + this.ctx.logger.debug(`Created temporary directory for step outputs: ${this.outputsDir}`); + + await fs.mkdir(this.envsDir, { recursive: true }); + this.ctx.logger.debug( + `Created temporary directory for step environment variables: ${this.envsDir}` + ); + + if (this.timeoutMs !== undefined) { + const abortController = new AbortController(); + + let timeoutId: NodeJS.Timeout | undefined; + const timeoutPromise = new Promise((_, reject) => { + timeoutId = setTimeout(() => { + // Reject with timeout error FIRST, before killing the process + // This ensures the timeout error wins the race + reject( + new BuildStepRuntimeError( + `Build step "${this.displayName}" timed out after ${this.timeoutMs}ms` + ) + ); + + abortController.abort(); + }, this.timeoutMs); + }); + + try { + await Promise.race([ + this.command !== undefined + ? this.executeCommandAsync({ signal: abortController.signal }) + : this.executeFnAsync({ signal: abortController.signal }), + timeoutPromise, + ]); + } finally { + if (timeoutId) { + clearTimeout(timeoutId); + } + } + } else { + const executionPromise = + this.command !== undefined + ? this.executeCommandAsync({ signal: null }) + : this.executeFnAsync({ signal: null }); + await executionPromise; + } + + this.ctx.logger.info( + { marker: BuildStepLogMarker.END_STEP, result: BuildStepStatus.SUCCESS }, + `Finished build step "${this.displayName}" successfully` + ); + this.status = BuildStepStatus.SUCCESS; + } catch (err) { + this.ctx.logger.error({ err }); + this.ctx.logger.error( + { marker: BuildStepLogMarker.END_STEP, result: BuildStepStatus.FAIL }, + `Build step "${this.displayName}" failed` + ); + this.status = BuildStepStatus.FAIL; + throw err; + } finally { + this.executed = true; + + try { + await this.collectAndValidateOutputsAsync(this.outputsDir); + await this.collectAndUpdateEnvsAsync(this.envsDir); + this.ctx.logger.debug('Finished collecting output parameters'); + } catch (error) { + // If the step succeeded, we expect the outputs to be collected successfully. + if (this.status === BuildStepStatus.SUCCESS) { + throw error; + } + + this.ctx.logger.debug({ err: error }, 'Failed to collect output parameters'); + } + + await cleanUpStepTemporaryDirectoriesAsync(this.ctx.global, this.id); + } + } + + public canBeRunOnRuntimePlatform(): boolean { + return ( + !this.supportedRuntimePlatforms || + this.supportedRuntimePlatforms.includes(this.ctx.global.runtimePlatform) + ); + } + + public shouldExecuteStep(): boolean { + const hasAnyPreviousStepFailed = this.ctx.global.hasAnyPreviousStepFailed; + + if (!this.ifCondition) { + return !hasAnyPreviousStepFailed; + } + + let ifCondition = this.ifCondition; + + if (ifCondition.startsWith('${{') && ifCondition.endsWith('}}')) { + ifCondition = ifCondition.slice(3, -2); + } else if (ifCondition.startsWith('${') && ifCondition.endsWith('}')) { + ifCondition = ifCondition.slice(2, -1); + } + + return Boolean( + jsepEval(ifCondition, { + inputs: + this.inputs?.reduce( + (acc, input) => { + acc[input.id] = input.getValue({ + interpolationContext: this.getInterpolationContext(), + }); + return acc; + }, + {} as Record + ) ?? {}, + eas: { + runtimePlatform: this.ctx.global.runtimePlatform, + ...this.ctx.global.staticContext, + env: this.getScriptEnv(), + }, + ...this.getInterpolationContext(), + }) + ); + } + + public skip(): void { + this.status = BuildStepStatus.SKIPPED; + this.ctx.logger.info( + { marker: BuildStepLogMarker.START_STEP }, + 'Executing build step "${this.displayName}"' + ); + this.ctx.logger.info(`Skipped build step "${this.displayName}"`); + this.ctx.logger.info( + { marker: BuildStepLogMarker.END_STEP, result: BuildStepStatus.SKIPPED }, + `Skipped build step "${this.displayName}"` + ); + } + + private getInterpolationContext(): JobInterpolationContext { + return { + ...this.ctx.global.getInterpolationContext(), + env: this.getScriptEnv(), + }; + } + + private async executeCommandAsync({ signal }: { signal: AbortSignal | null }): Promise { + assert(this.command, 'Command must be defined.'); + + const interpolatedCommand = interpolateJobContext({ + target: this.command, + context: this.getInterpolationContext(), + }); + + const command = this.interpolateInputsOutputsAndGlobalContextInTemplate( + `${interpolatedCommand}`, + this.inputs + ); + this.ctx.logger.debug(`Interpolated inputs in the command template`); + + const scriptPath = await saveScriptToTemporaryFileAsync(this.ctx.global, this.id, command); + this.ctx.logger.debug(`Saved script to ${scriptPath}`); + + const { command: shellCommand, args } = getShellCommandAndArgs(this.shell, scriptPath); + this.ctx.logger.debug( + `Executing script: ${shellCommand}${args !== undefined ? ` ${args.join(' ')}` : ''}` + ); + + try { + const workingDirectoryStat = await fs.stat(this.ctx.workingDirectory); + if (!workingDirectoryStat.isDirectory()) { + this.ctx.logger.error( + `Working directory "${this.ctx.workingDirectory}" exists, but is not a directory` + ); + } + } catch (err: any) { + if (err?.code === 'ENOENT') { + this.ctx.logger.error( + { err }, + `Working directory "${this.ctx.workingDirectory}" does not exist` + ); + } else { + this.ctx.logger.error( + { err }, + `Cannot access working directory "${this.ctx.workingDirectory}"` + ); + } + } + + await spawnAsync(shellCommand, args ?? [], { + cwd: this.ctx.workingDirectory, + logger: this.ctx.logger, + env: this.getScriptEnv(), + // stdin is /dev/null, std{out,err} are piped into logger. + stdio: ['ignore', 'pipe', 'pipe'], + signal: signal ?? undefined, + }); + this.ctx.logger.debug(`Script completed successfully`); + } + + private async executeFnAsync({ signal }: { signal: AbortSignal | null }): Promise { + assert(this.fn, 'Function (fn) must be defined'); + + await this.fn(this.ctx, { + inputs: Object.fromEntries( + Object.entries(this.inputById).map(([key, input]) => [ + key, + { value: input.getValue({ interpolationContext: this.getInterpolationContext() }) }, + ]) + ), + outputs: this.outputById, + env: this.getScriptEnv(), + signal: signal ?? undefined, + }); + + this.ctx.logger.debug(`Script completed successfully`); + } + + private interpolateInputsOutputsAndGlobalContextInTemplate( + template: string, + inputs?: BuildStepInput[] + ): string { + if (!inputs) { + return interpolateWithOutputs( + this.ctx.global.interpolate(template), + (path) => this.ctx.global.getStepOutputValue(path) ?? '' + ); + } + const vars = inputs.reduce( + (acc, input) => { + const value = input.getValue({ interpolationContext: this.getInterpolationContext() }); + acc[input.id] = typeof value === 'object' ? JSON.stringify(value) : value?.toString() ?? ''; + return acc; + }, + {} as Record + ); + return interpolateWithOutputs( + interpolateWithInputs(this.ctx.global.interpolate(template), vars), + (path) => this.ctx.global.getStepOutputValue(path) ?? '' + ); + } + + private async collectAndValidateOutputsAsync(outputsDir: string): Promise { + const files = await fs.readdir(outputsDir); + + for (const outputId of files) { + if (!(outputId in this.outputById)) { + const newOutput = new BuildStepOutput(this.ctx.global, { + id: outputId, + stepDisplayName: this.displayName, + required: false, + }); + this.outputById[outputId] = newOutput; + } + + const file = path.join(outputsDir, outputId); + const rawContents = await fs.readFile(file, 'utf-8'); + const decodedContents = Buffer.from(rawContents, 'base64').toString('utf-8'); + this.outputById[outputId].set(decodedContents); + } + + const nonSetRequiredOutputIds: string[] = []; + for (const output of Object.values(this.outputById)) { + try { + const value = output.value; + this.ctx.logger.debug(`Output parameter "${output.id}" is set to "${value}"`); + } catch (err) { + this.ctx.logger.debug({ err }, `Getting value for output parameter "${output.id}" failed.`); + nonSetRequiredOutputIds.push(output.id); + } + } + if (nonSetRequiredOutputIds.length > 0) { + const idsString = nonSetRequiredOutputIds.map((i) => `"${i}"`).join(', '); + throw new BuildStepRuntimeError(`Some required outputs have not been set: ${idsString}`, { + metadata: { ids: nonSetRequiredOutputIds }, + }); + } + } + + private async collectAndUpdateEnvsAsync(envsDir: string): Promise { + const filenames = await fs.readdir(envsDir); + + const entries = await Promise.all( + filenames.map(async (basename) => { + const rawContents = await fs.readFile(path.join(envsDir, basename), 'utf-8'); + const decodedContents = Buffer.from(rawContents, 'base64').toString('utf-8'); + return [basename, decodedContents]; + }) + ); + this.ctx.global.updateEnv({ + ...this.ctx.global.env, + ...Object.fromEntries(entries), + }); + } + + private getScriptEnv(): Record { + const effectiveEnv = { ...this.ctx.global.env, ...this.stepEnvOverrides }; + const currentPath = effectiveEnv.PATH ?? process.env.PATH; + const newPath = currentPath ? `${BIN_PATH}:${currentPath}` : BIN_PATH; + return { + ...effectiveEnv, + __EXPO_STEPS_OUTPUTS_DIR: this.outputsDir, + __EXPO_STEPS_ENVS_DIR: this.envsDir, + __EXPO_STEPS_WORKING_DIRECTORY: this.ctx.workingDirectory, + PATH: newPath, + }; + } +} diff --git a/packages/steps/src/BuildStepContext.ts b/packages/steps/src/BuildStepContext.ts new file mode 100644 index 0000000000..94be51cd8d --- /dev/null +++ b/packages/steps/src/BuildStepContext.ts @@ -0,0 +1,319 @@ +import os from 'os'; +import path from 'path'; + +import fg from 'fast-glob'; +import { Env, JobInterpolationContext, StaticJobInterpolationContext } from '@expo/eas-build-job'; +import { bunyan } from '@expo/logger'; +import { v4 as uuidv4 } from 'uuid'; + +import { hashFiles } from './utils/hashFiles.js'; +import { + BuildStep, + BuildStepOutputAccessor, + SerializedBuildStepOutputAccessor, +} from './BuildStep.js'; +import { + getObjectValueForInterpolation, + interpolateWithGlobalContext, + parseOutputPath, +} from './utils/template.js'; +import { BuildStepRuntimeError } from './errors.js'; +import { BuildRuntimePlatform } from './BuildRuntimePlatform.js'; +import { BuildStepEnv } from './BuildStepEnv.js'; + +interface SerializedExternalBuildContextProvider { + projectSourceDirectory: string; + projectTargetDirectory: string; + defaultWorkingDirectory: string; + buildLogsDirectory: string; + runtimePlatform: BuildRuntimePlatform; + // We omit steps, because they should be calculated live based on global context. + staticContext: Omit; + env: BuildStepEnv; +} + +export interface ExternalBuildContextProvider { + readonly projectSourceDirectory: string; + readonly projectTargetDirectory: string; + readonly defaultWorkingDirectory: string; + readonly buildLogsDirectory: string; + readonly runtimePlatform: BuildRuntimePlatform; + readonly logger: bunyan; + + readonly staticContext: () => Omit; + + readonly env: BuildStepEnv; + updateEnv(env: BuildStepEnv): void; +} + +export interface SerializedBuildStepGlobalContext { + stepsInternalBuildDirectory: string; + stepById: Record; + provider: SerializedExternalBuildContextProvider; + skipCleanup: boolean; +} + +export class BuildStepGlobalContext { + public stepsInternalBuildDirectory: string; + public readonly runtimePlatform: BuildRuntimePlatform; + public readonly baseLogger: bunyan; + private didCheckOut = false; + private _hasAnyPreviousStepFailed = false; + private stepById: Record = {}; + + constructor( + private readonly provider: ExternalBuildContextProvider, + public readonly skipCleanup: boolean + ) { + this.stepsInternalBuildDirectory = path.join(os.tmpdir(), 'eas-build', uuidv4()); + this.runtimePlatform = provider.runtimePlatform; + this.baseLogger = provider.logger; + this._hasAnyPreviousStepFailed = false; + } + + public get projectSourceDirectory(): string { + return this.provider.projectSourceDirectory; + } + + public get projectTargetDirectory(): string { + return this.provider.projectTargetDirectory; + } + + public get defaultWorkingDirectory(): string { + return this.didCheckOut ? this.provider.defaultWorkingDirectory : this.projectTargetDirectory; + } + + public get buildLogsDirectory(): string { + return this.provider.buildLogsDirectory; + } + + public get env(): BuildStepEnv { + return this.provider.env; + } + + public get staticContext(): StaticJobInterpolationContext { + return { + ...this.provider.staticContext(), + steps: Object.fromEntries( + Object.values(this.stepById).map((step) => [ + step.id, + { + outputs: Object.fromEntries( + step.outputs.map((output) => { + return [output.id, output.rawValue]; + }) + ), + }, + ]) + ), + }; + } + + public updateEnv(updatedEnv: BuildStepEnv): void { + this.provider.updateEnv(updatedEnv); + } + + public registerStep(step: BuildStep): void { + this.stepById[step.id] = step; + } + + public getStepOutputValue(path: string): string | undefined { + const { stepId, outputId } = parseOutputPath(path); + if (!(stepId in this.stepById)) { + throw new BuildStepRuntimeError(`Step "${stepId}" does not exist.`); + } + return this.stepById[stepId].getOutputValueByName(outputId); + } + + public getInterpolationContext(): JobInterpolationContext { + const hasAnyPreviousStepFailed = this.hasAnyPreviousStepFailed; + + return { + ...this.staticContext, + always: () => true, + never: () => false, + success: () => !hasAnyPreviousStepFailed, + failure: () => hasAnyPreviousStepFailed, + env: this.env as Env, + fromJSON: (json: string) => JSON.parse(json), + toJSON: (value: unknown) => JSON.stringify(value), + contains: (value, substring) => value.includes(substring), + startsWith: (value, prefix) => value.startsWith(prefix), + endsWith: (value, suffix) => value.endsWith(suffix), + hashFiles: (...patterns: string[]) => this.hashFiles(...patterns), + replaceAll: (input: string, stringToReplace: string, replacementString: string) => { + while (input.includes(stringToReplace)) { + input = input.replace(stringToReplace, replacementString); + } + return input; + }, + substring: (input: string, start: number, end?: number) => input.substring(start, end), + }; + } + + public interpolate( + value: InterpolableType + ): InterpolableType { + return interpolateWithGlobalContext(value, (path) => { + return ( + getObjectValueForInterpolation(path, { + eas: { + runtimePlatform: this.runtimePlatform, + ...this.staticContext, + env: this.env, + }, + })?.toString() ?? '' + ); + }); + } + + public stepCtx(options: { logger: bunyan; relativeWorkingDirectory?: string }): BuildStepContext { + return new BuildStepContext(this, options); + } + + public markAsCheckedOut(logger: bunyan): void { + this.didCheckOut = true; + logger.info( + `Changing default working directory to ${this.defaultWorkingDirectory} (was ${this.projectTargetDirectory})` + ); + } + + public get hasAnyPreviousStepFailed(): boolean { + return this._hasAnyPreviousStepFailed; + } + + public markAsFailed(): void { + this._hasAnyPreviousStepFailed = true; + } + + public wasCheckedOut(): boolean { + return this.didCheckOut; + } + + public hashFiles(...patterns: string[]): string { + const cwd = this.defaultWorkingDirectory; + const workspacePath = path.resolve(cwd); + + // Use glob to find matching files across all patterns + const filePaths = fg.sync(patterns, { + cwd, + absolute: true, + onlyFiles: true, + }); + + if (filePaths.length === 0) { + return ''; + } + + const validFilePaths = filePaths.filter((file) => + file.startsWith(`${workspacePath}${path.sep}`) + ); + + if (validFilePaths.length === 0) { + return ''; + } + + return hashFiles(validFilePaths); + } + + public serialize(): SerializedBuildStepGlobalContext { + return { + stepsInternalBuildDirectory: this.stepsInternalBuildDirectory, + stepById: Object.fromEntries( + Object.entries(this.stepById).map(([id, step]) => [id, step.serialize()]) + ), + provider: { + projectSourceDirectory: this.provider.projectSourceDirectory, + projectTargetDirectory: this.provider.projectTargetDirectory, + defaultWorkingDirectory: this.provider.defaultWorkingDirectory, + buildLogsDirectory: this.provider.buildLogsDirectory, + runtimePlatform: this.provider.runtimePlatform, + staticContext: this.provider.staticContext(), + env: this.provider.env, + }, + skipCleanup: this.skipCleanup, + }; + } + + public static deserialize( + serialized: SerializedBuildStepGlobalContext, + logger: bunyan + ): BuildStepGlobalContext { + const deserializedProvider: ExternalBuildContextProvider = { + projectSourceDirectory: serialized.provider.projectSourceDirectory, + projectTargetDirectory: serialized.provider.projectTargetDirectory, + defaultWorkingDirectory: serialized.provider.defaultWorkingDirectory, + buildLogsDirectory: serialized.provider.buildLogsDirectory, + runtimePlatform: serialized.provider.runtimePlatform, + logger, + staticContext: () => serialized.provider.staticContext, + env: serialized.provider.env, + updateEnv: () => {}, + }; + const ctx = new BuildStepGlobalContext(deserializedProvider, serialized.skipCleanup); + for (const [id, stepOutputAccessor] of Object.entries(serialized.stepById)) { + ctx.stepById[id] = BuildStepOutputAccessor.deserialize(stepOutputAccessor); + } + ctx.stepsInternalBuildDirectory = serialized.stepsInternalBuildDirectory; + + return ctx; + } +} + +export interface SerializedBuildStepContext { + relativeWorkingDirectory?: string; + global: SerializedBuildStepGlobalContext; +} + +export class BuildStepContext { + public readonly logger: bunyan; + public readonly relativeWorkingDirectory?: string; + + constructor( + private readonly ctx: BuildStepGlobalContext, + { + logger, + relativeWorkingDirectory, + }: { + logger: bunyan; + relativeWorkingDirectory?: string; + } + ) { + this.logger = logger ?? ctx.baseLogger; + this.relativeWorkingDirectory = relativeWorkingDirectory; + } + + public get global(): BuildStepGlobalContext { + return this.ctx; + } + + public get workingDirectory(): string { + if (!this.relativeWorkingDirectory) { + return this.ctx.defaultWorkingDirectory; + } + + if (path.isAbsolute(this.relativeWorkingDirectory)) { + return path.join(this.ctx.projectTargetDirectory, this.relativeWorkingDirectory); + } + + return path.join(this.ctx.defaultWorkingDirectory, this.relativeWorkingDirectory); + } + + public serialize(): SerializedBuildStepContext { + return { + relativeWorkingDirectory: this.relativeWorkingDirectory, + global: this.ctx.serialize(), + }; + } + + public static deserialize( + serialized: SerializedBuildStepContext, + logger: bunyan + ): BuildStepContext { + const deserializedGlobal = BuildStepGlobalContext.deserialize(serialized.global, logger); + return new BuildStepContext(deserializedGlobal, { + logger, + relativeWorkingDirectory: serialized.relativeWorkingDirectory, + }); + } +} diff --git a/packages/steps/src/BuildStepEnv.ts b/packages/steps/src/BuildStepEnv.ts new file mode 100644 index 0000000000..bc4205ee80 --- /dev/null +++ b/packages/steps/src/BuildStepEnv.ts @@ -0,0 +1 @@ +export type BuildStepEnv = Record; diff --git a/packages/steps/src/BuildStepInput.ts b/packages/steps/src/BuildStepInput.ts new file mode 100644 index 0000000000..f553ba51a1 --- /dev/null +++ b/packages/steps/src/BuildStepInput.ts @@ -0,0 +1,241 @@ +import assert from 'assert'; + +import { JobInterpolationContext } from '@expo/eas-build-job'; + +import { BuildStepGlobalContext } from './BuildStepContext.js'; +import { BuildStepRuntimeError } from './errors.js'; +import { + BUILD_STEP_OR_BUILD_GLOBAL_CONTEXT_REFERENCE_REGEX, + interpolateWithOutputs, +} from './utils/template.js'; +import { interpolateJobContext } from './interpolation.js'; + +export enum BuildStepInputValueTypeName { + STRING = 'string', + BOOLEAN = 'boolean', + NUMBER = 'number', + JSON = 'json', +} + +export type BuildStepInputValueType< + T extends BuildStepInputValueTypeName = BuildStepInputValueTypeName, +> = T extends BuildStepInputValueTypeName.STRING + ? string + : T extends BuildStepInputValueTypeName.BOOLEAN + ? boolean + : T extends BuildStepInputValueTypeName.NUMBER + ? number + : Record; + +export type BuildStepInputById = Record; +export type BuildStepInputProvider = ( + ctx: BuildStepGlobalContext, + stepId: string +) => BuildStepInput; + +interface BuildStepInputProviderParams< + T extends BuildStepInputValueTypeName = BuildStepInputValueTypeName, + R extends boolean = boolean, +> { + id: string; + allowedValues?: unknown[]; + defaultValue?: unknown; + required: R; + allowedValueTypeName: T; +} + +interface BuildStepInputParams + extends BuildStepInputProviderParams { + stepDisplayName: string; +} + +export class BuildStepInput< + T extends BuildStepInputValueTypeName = BuildStepInputValueTypeName, + R extends boolean = boolean, +> { + public readonly id: string; + public readonly stepDisplayName: string; + public readonly defaultValue?: unknown; + public readonly allowedValues?: unknown[]; + public readonly allowedValueTypeName: T; + public readonly required: R; + + private _value?: unknown; + + public static createProvider(params: BuildStepInputProviderParams): BuildStepInputProvider { + return (ctx, stepDisplayName) => new BuildStepInput(ctx, { ...params, stepDisplayName }); + } + + constructor( + private readonly ctx: BuildStepGlobalContext, + { + id, + stepDisplayName, + allowedValues, + defaultValue, + required, + allowedValueTypeName, + }: BuildStepInputParams + ) { + this.id = id; + this.stepDisplayName = stepDisplayName; + this.allowedValues = allowedValues; + this.defaultValue = defaultValue; + this.required = required; + this.allowedValueTypeName = allowedValueTypeName; + } + + public getValue({ + interpolationContext, + }: { + interpolationContext: JobInterpolationContext; + }): R extends true ? BuildStepInputValueType : BuildStepInputValueType | undefined { + const rawValue = this._value ?? this.defaultValue; + if (this.required && rawValue === undefined) { + throw new BuildStepRuntimeError( + `Input parameter "${this.id}" for step "${this.stepDisplayName}" is required but it was not set.` + ); + } + + const interpolatedValue = interpolateJobContext({ + target: rawValue, + context: interpolationContext, + }); + + const valueDoesNotRequireInterpolation = + interpolatedValue === undefined || + interpolatedValue === null || + typeof interpolatedValue === 'boolean' || + typeof interpolatedValue === 'number'; + let returnValue; + if (valueDoesNotRequireInterpolation) { + if ( + typeof interpolatedValue !== this.allowedValueTypeName && + interpolatedValue !== undefined + ) { + throw new BuildStepRuntimeError( + `Input parameter "${this.id}" for step "${this.stepDisplayName}" must be of type "${this.allowedValueTypeName}".` + ); + } + returnValue = interpolatedValue as BuildStepInputValueType; + } else { + // `valueDoesNotRequireInterpolation` checks that `rawValue` is not undefined + // so this will never be true. + assert(interpolatedValue !== undefined); + const valueInterpolatedWithGlobalContext = this.ctx.interpolate(interpolatedValue); + const valueInterpolatedWithOutputsAndGlobalContext = interpolateWithOutputs( + valueInterpolatedWithGlobalContext, + (path) => this.ctx.getStepOutputValue(path) ?? '' + ); + returnValue = this.parseInputValueToAllowedType(valueInterpolatedWithOutputsAndGlobalContext); + } + return returnValue; + } + + public get rawValue(): unknown { + return this._value ?? this.defaultValue; + } + + public set(value: unknown): BuildStepInput { + if (this.required && value === undefined) { + throw new BuildStepRuntimeError( + `Input parameter "${this.id}" for step "${this.stepDisplayName}" is required.` + ); + } + + this._value = value; + return this; + } + + public isRawValueOneOfAllowedValues(): boolean { + const value = this._value ?? this.defaultValue; + if (this.allowedValues === undefined || value === undefined) { + return true; + } + return this.allowedValues.includes(value); + } + + public isRawValueStepOrContextReference(): boolean { + return ( + typeof this.rawValue === 'string' && + (!!BUILD_STEP_OR_BUILD_GLOBAL_CONTEXT_REFERENCE_REGEX.exec(this.rawValue) || + // If value is an interpolation reference we're going to render whatever it evaluates to. + // See `interpolateJobContext`. + (this.rawValue.startsWith('${{') && this.rawValue.endsWith('}}'))) + ); + } + + private parseInputValueToAllowedType( + value: string | object | boolean | number + ): BuildStepInputValueType { + if (typeof value === 'object') { + return value as BuildStepInputValueType; + } + if (this.allowedValueTypeName === BuildStepInputValueTypeName.STRING) { + return this.parseInputValueToString(value) as BuildStepInputValueType; + } else if (this.allowedValueTypeName === BuildStepInputValueTypeName.NUMBER) { + return this.parseInputValueToNumber(value) as BuildStepInputValueType; + } else if (this.allowedValueTypeName === BuildStepInputValueTypeName.BOOLEAN) { + return this.parseInputValueToBoolean(value) as BuildStepInputValueType; + } else { + return this.parseInputValueToObject(value) as BuildStepInputValueType; + } + } + + private parseInputValueToString(value: string | boolean | number): string { + let parsedValue = value; + try { + parsedValue = JSON.parse(`"${value}"`); + } catch (err) { + if (!(err instanceof SyntaxError)) { + throw err; + } + } + return parsedValue as string; + } + + private parseInputValueToNumber(value: string | boolean | number): number { + const numberValue = Number(value); + if (Number.isNaN(numberValue)) { + throw new BuildStepRuntimeError( + `Input parameter "${this.id}" for step "${this.stepDisplayName}" must be of type "${this.allowedValueTypeName}".` + ); + } + return numberValue; + } + + private parseInputValueToBoolean(value: string | boolean | number): boolean { + if (value === 'true' || value === true) { + return true; + } else if (value === 'false' || value === false) { + return false; + } else { + throw new BuildStepRuntimeError( + `Input parameter "${this.id}" for step "${this.stepDisplayName}" must be of type "${this.allowedValueTypeName}".` + ); + } + } + + private parseInputValueToObject(value: string | boolean | number): Record { + try { + return JSON.parse(value as string); + } catch (e: any) { + throw new BuildStepRuntimeError( + `Input parameter "${this.id}" for step "${this.stepDisplayName}" must be of type "${this.allowedValueTypeName}".`, + { + cause: e, + } + ); + } + } +} + +export function makeBuildStepInputByIdMap(inputs?: BuildStepInput[]): BuildStepInputById { + if (inputs === undefined) { + return {}; + } + return inputs.reduce((acc, input) => { + acc[input.id] = input; + return acc; + }, {} as BuildStepInputById); +} diff --git a/packages/steps/src/BuildStepOutput.ts b/packages/steps/src/BuildStepOutput.ts new file mode 100644 index 0000000000..d31375d505 --- /dev/null +++ b/packages/steps/src/BuildStepOutput.ts @@ -0,0 +1,102 @@ +import { BuildStepGlobalContext } from './BuildStepContext.js'; +import { BuildStepRuntimeError } from './errors.js'; + +export type BuildStepOutputById = Record; +export type BuildStepOutputProvider = ( + ctx: BuildStepGlobalContext, + stepDisplayName: string +) => BuildStepOutput; + +interface BuildStepOutputProviderParams { + id: string; + required: R; +} + +interface BuildStepOutputParams + extends BuildStepOutputProviderParams { + stepDisplayName: string; +} + +type BuildStepOutputValueType = R extends true + ? string + : string | undefined; + +export interface SerializedBuildStepOutput { + id: string; + stepDisplayName: string; + required: R; + value?: string; +} + +export class BuildStepOutput { + public readonly id: string; + public readonly stepDisplayName: string; + public readonly required: R; + + private _value?: string; + + public static createProvider(params: BuildStepOutputProviderParams): BuildStepOutputProvider { + return (ctx, stepDisplayName) => new BuildStepOutput(ctx, { ...params, stepDisplayName }); + } + + constructor( + private readonly ctx: BuildStepGlobalContext | undefined, + { id, stepDisplayName, required }: BuildStepOutputParams + ) { + this.id = id; + this.stepDisplayName = stepDisplayName; + this.required = required; + } + + public get rawValue(): string | undefined { + return this._value; + } + + public get value(): BuildStepOutputValueType { + if (this.required && this._value === undefined) { + throw new BuildStepRuntimeError( + `Output parameter "${this.id}" for step "${this.stepDisplayName}" is required but it was not set.` + ); + } + return this._value as BuildStepOutputValueType; + } + + public set(value: BuildStepOutputValueType): BuildStepOutput { + if (this.required && value === undefined) { + throw new BuildStepRuntimeError( + `Output parameter "${this.id}" for step "${this.stepDisplayName}" is required.` + ); + } + this._value = value; + return this; + } + + public serialize(): SerializedBuildStepOutput { + return { + id: this.id, + stepDisplayName: this.stepDisplayName, + required: this.required, + value: this._value, + }; + } + + public static deserialize(serialized: SerializedBuildStepOutput): BuildStepOutput { + const deserialized = new BuildStepOutput(undefined, { + id: serialized.id, + stepDisplayName: serialized.stepDisplayName, + required: serialized.required, + }); + deserialized._value = serialized.value; + return deserialized; + } +} + +export function makeBuildStepOutputByIdMap(outputs?: BuildStepOutput[]): BuildStepOutputById { + if (outputs === undefined) { + return {}; + } + return outputs.reduce((acc, output) => { + acc[output.id] = output; + return acc; + }, {} as BuildStepOutputById); +} diff --git a/packages/steps/src/BuildTemporaryFiles.ts b/packages/steps/src/BuildTemporaryFiles.ts new file mode 100644 index 0000000000..b1eba7db2c --- /dev/null +++ b/packages/steps/src/BuildTemporaryFiles.ts @@ -0,0 +1,46 @@ +import path from 'path'; +import fs from 'fs/promises'; + +import { v4 as uuidv4 } from 'uuid'; + +import { BuildStepGlobalContext } from './BuildStepContext.js'; + +export async function saveScriptToTemporaryFileAsync( + ctx: BuildStepGlobalContext, + stepId: string, + scriptContents: string +): Promise { + const scriptsDir = getTemporaryScriptsDirPath(ctx, stepId); + await fs.mkdir(scriptsDir, { recursive: true }); + const temporaryScriptPath = path.join(scriptsDir, `${uuidv4()}.sh`); + await fs.writeFile(temporaryScriptPath, scriptContents); + return temporaryScriptPath; +} + +export async function cleanUpStepTemporaryDirectoriesAsync( + ctx: BuildStepGlobalContext, + stepId: string +): Promise { + if (ctx.skipCleanup) { + return; + } + const stepTemporaryDirectory = getTemporaryStepDirPath(ctx, stepId); + await fs.rm(stepTemporaryDirectory, { recursive: true, force: true }); + ctx.baseLogger.debug({ stepTemporaryDirectory }, 'Removed step temporary directory'); +} + +function getTemporaryStepDirPath(ctx: BuildStepGlobalContext, stepId: string): string { + return path.join(ctx.stepsInternalBuildDirectory, 'steps', stepId); +} + +function getTemporaryScriptsDirPath(ctx: BuildStepGlobalContext, stepId: string): string { + return path.join(getTemporaryStepDirPath(ctx, stepId), 'scripts'); +} + +export function getTemporaryOutputsDirPath(ctx: BuildStepGlobalContext, stepId: string): string { + return path.join(getTemporaryStepDirPath(ctx, stepId), 'outputs'); +} + +export function getTemporaryEnvsDirPath(ctx: BuildStepGlobalContext, stepId: string): string { + return path.join(getTemporaryStepDirPath(ctx, stepId), 'envs'); +} diff --git a/packages/steps/src/BuildWorkflow.ts b/packages/steps/src/BuildWorkflow.ts new file mode 100644 index 0000000000..94df2617f4 --- /dev/null +++ b/packages/steps/src/BuildWorkflow.ts @@ -0,0 +1,47 @@ +import { BuildFunctionById } from './BuildFunction.js'; +import { BuildStep } from './BuildStep.js'; +import { BuildStepGlobalContext } from './BuildStepContext.js'; + +export class BuildWorkflow { + public readonly buildSteps: BuildStep[]; + public readonly buildFunctions: BuildFunctionById; + + constructor( + private readonly ctx: BuildStepGlobalContext, + { buildSteps, buildFunctions }: { buildSteps: BuildStep[]; buildFunctions: BuildFunctionById } + ) { + this.buildSteps = buildSteps; + this.buildFunctions = buildFunctions; + } + + public async executeAsync(): Promise { + let maybeError: Error | null = null; + for (const step of this.buildSteps) { + let shouldExecuteStep = false; + try { + shouldExecuteStep = step.shouldExecuteStep(); + } catch (err: any) { + step.ctx.logger.error({ err }); + step.ctx.logger.error( + `Runner failed to evaluate if it should execute step "${step.displayName}", using step's if condition "${step.ifCondition}". This can be caused by trying to access non-existing object property. If you think this is a bug report it here: https://github.com/expo/eas-cli/issues.` + ); + maybeError = maybeError ?? err; + this.ctx.markAsFailed(); + } + if (shouldExecuteStep) { + try { + await step.executeAsync(); + } catch (err: any) { + maybeError = maybeError ?? err; + this.ctx.markAsFailed(); + } + } else { + step.skip(); + } + } + + if (maybeError) { + throw maybeError; + } + } +} diff --git a/packages/steps/src/BuildWorkflowValidator.ts b/packages/steps/src/BuildWorkflowValidator.ts new file mode 100644 index 0000000000..2c9ee8488a --- /dev/null +++ b/packages/steps/src/BuildWorkflowValidator.ts @@ -0,0 +1,169 @@ +import path from 'path'; + +import fs from 'fs-extra'; + +import { BuildStep } from './BuildStep.js'; +import { BuildStepInputValueTypeName } from './BuildStepInput.js'; +import { BuildWorkflow } from './BuildWorkflow.js'; +import { BuildConfigError, BuildWorkflowError } from './errors.js'; +import { duplicates } from './utils/expodash/duplicates.js'; +import { nullthrows } from './utils/nullthrows.js'; +import { findOutputPaths } from './utils/template.js'; + +export class BuildWorkflowValidator { + constructor(private readonly workflow: BuildWorkflow) {} + + public async validateAsync(): Promise { + const errors: BuildConfigError[] = []; + errors.push(...this.validateUniqueStepIds()); + errors.push(...this.validateInputs()); + errors.push(...this.validateAllowedPlatforms()); + errors.push(...(await this.validateCustomFunctionModulesAsync())); + if (errors.length !== 0) { + throw new BuildWorkflowError('Build workflow is invalid.', errors); + } + } + + private validateUniqueStepIds(): BuildConfigError[] { + const stepIds = this.workflow.buildSteps.map(({ id }) => id); + const duplicatedStepIds = duplicates(stepIds); + if (duplicatedStepIds.length === 0) { + return []; + } else { + const error = new BuildConfigError( + `Duplicated step IDs: ${duplicatedStepIds.map((i) => `"${i}"`).join(', ')}` + ); + return [error]; + } + } + + private validateInputs(): BuildConfigError[] { + const errors: BuildConfigError[] = []; + + const allStepIds = new Set(this.workflow.buildSteps.map((s) => s.id)); + const visitedStepByStepId: Record = {}; + for (const currentStep of this.workflow.buildSteps) { + for (const currentStepInput of currentStep.inputs ?? []) { + if (currentStepInput.required && currentStepInput.rawValue === undefined) { + const error = new BuildConfigError( + `Input parameter "${currentStepInput.id}" for step "${currentStep.displayName}" is required but it was not set.` + ); + errors.push(error); + } + + const currentType = + typeof currentStepInput.rawValue === 'object' + ? BuildStepInputValueTypeName.JSON + : typeof currentStepInput.rawValue; + if ( + currentStepInput.rawValue !== undefined && + !currentStepInput.isRawValueStepOrContextReference() && + currentType !== currentStepInput.allowedValueTypeName + ) { + const error = new BuildConfigError( + `Input parameter "${currentStepInput.id}" for step "${ + currentStep.displayName + }" is set to "${ + typeof currentStepInput.rawValue === 'object' + ? JSON.stringify(currentStepInput.rawValue) + : currentStepInput.rawValue + }" which is not of type "${ + currentStepInput.allowedValueTypeName + }" or is not step or context reference.` + ); + errors.push(error); + } + + if (currentStepInput.defaultValue === undefined) { + continue; + } + if (!currentStepInput.isRawValueOneOfAllowedValues()) { + const error = new BuildConfigError( + `Input parameter "${currentStepInput.id}" for step "${ + currentStep.displayName + }" is set to "${currentStepInput.rawValue}" which is not one of the allowed values: ${nullthrows( + currentStepInput.allowedValues + ) + .map((i) => `"${i}"`) + .join(', ')}.` + ); + errors.push(error); + } + const paths = + typeof currentStepInput.defaultValue === 'string' + ? findOutputPaths(currentStepInput.defaultValue) + : []; + for (const { stepId: referencedStepId, outputId: referencedStepOutputId } of paths) { + if (!(referencedStepId in visitedStepByStepId)) { + if (allStepIds.has(referencedStepId)) { + const error = new BuildConfigError( + `Input parameter "${currentStepInput.id}" for step "${currentStep.displayName}" uses an expression that references an output parameter from the future step "${referencedStepId}".` + ); + errors.push(error); + } else { + const error = new BuildConfigError( + `Input parameter "${currentStepInput.id}" for step "${currentStep.displayName}" uses an expression that references an output parameter from a non-existent step "${referencedStepId}".` + ); + errors.push(error); + } + } else { + if (!visitedStepByStepId[referencedStepId].hasOutputParameter(referencedStepOutputId)) { + const error = new BuildConfigError( + `Input parameter "${currentStepInput.id}" for step "${currentStep.displayName}" uses an expression that references an undefined output parameter "${referencedStepOutputId}" from step "${referencedStepId}".` + ); + errors.push(error); + } + } + } + } + visitedStepByStepId[currentStep.id] = currentStep; + } + + return errors; + } + + private validateAllowedPlatforms(): BuildConfigError[] { + const errors: BuildConfigError[] = []; + for (const step of this.workflow.buildSteps) { + if (!step.canBeRunOnRuntimePlatform()) { + const error = new BuildConfigError( + `Step "${step.displayName}" is not allowed on platform "${ + step.ctx.global.runtimePlatform + }". Allowed platforms for this step are: ${nullthrows( + step.supportedRuntimePlatforms, + `step.supportedRuntimePlatforms can't be falsy if canBeRunOnRuntimePlatform() is false` + ) + .map((p) => `"${p}"`) + .join(', ')}.` + ); + errors.push(error); + } + } + return errors; + } + + private async validateCustomFunctionModulesAsync(): Promise { + const errors: BuildConfigError[] = []; + for (const buildFunction of Object.values(this.workflow.buildFunctions)) { + if (!buildFunction.customFunctionModulePath) { + continue; + } + + if (!(await fs.exists(buildFunction.customFunctionModulePath))) { + const error = new BuildConfigError( + `Custom function module path "${buildFunction.customFunctionModulePath}" for function "${buildFunction.id}" does not exist.` + ); + errors.push(error); + continue; + } + + if (!(await fs.exists(path.join(buildFunction.customFunctionModulePath, 'package.json')))) { + const error = new BuildConfigError( + `Custom function module path "${buildFunction.customFunctionModulePath}" for function "${buildFunction.id}" does not contain a package.json file.` + ); + errors.push(error); + } + } + return errors; + } +} diff --git a/packages/steps/src/StepsConfigParser.ts b/packages/steps/src/StepsConfigParser.ts new file mode 100644 index 0000000000..db3996c4d1 --- /dev/null +++ b/packages/steps/src/StepsConfigParser.ts @@ -0,0 +1,218 @@ +import assert from 'node:assert'; + +import { + FunctionStep, + isStepFunctionStep, + isStepShellStep, + ShellStep, + Step, + validateSteps, +} from '@expo/eas-build-job'; + +import { BuildFunction, BuildFunctionById } from './BuildFunction.js'; +import { + BuildFunctionGroup, + BuildFunctionGroupById, + createBuildFunctionGroupByIdMapping, +} from './BuildFunctionGroup.js'; +import { BuildStepGlobalContext } from './BuildStepContext.js'; +import { BuildStep } from './BuildStep.js'; +import { AbstractConfigParser } from './AbstractConfigParser.js'; +import { BuildConfigError } from './errors.js'; +import { BuildStepOutput } from './BuildStepOutput.js'; + +export class StepsConfigParser extends AbstractConfigParser { + private readonly steps: Step[]; + + constructor( + ctx: BuildStepGlobalContext, + { + steps, + externalFunctions, + externalFunctionGroups, + }: { + steps: Step[]; + externalFunctions?: BuildFunction[]; + externalFunctionGroups?: BuildFunctionGroup[]; + } + ) { + super(ctx, { + externalFunctions, + externalFunctionGroups, + }); + + this.steps = steps; + } + + protected async parseConfigToBuildStepsAndBuildFunctionByIdMappingAsync(): Promise<{ + buildSteps: BuildStep[]; + buildFunctionById: BuildFunctionById; + }> { + const validatedSteps = validateSteps(this.steps); + StepsConfigParser.validateAllFunctionsExist(validatedSteps, { + externalFunctionIds: this.getExternalFunctionFullIds(), + externalFunctionGroupIds: this.getExternalFunctionGroupFullIds(), + }); + + const buildFunctionById = this.createBuildFunctionByIdMappingForExternalFunctions(); + const buildFunctionGroupById = createBuildFunctionGroupByIdMapping( + this.externalFunctionGroups ?? [] + ); + + const buildSteps: BuildStep[] = []; + for (const stepConfig of validatedSteps) { + buildSteps.push( + ...this.createBuildStepsFromStepConfig(stepConfig, { + buildFunctionById, + buildFunctionGroupById, + }) + ); + } + + return { + buildSteps, + buildFunctionById, + }; + } + + private createBuildFunctionByIdMappingForExternalFunctions(): BuildFunctionById { + const result: BuildFunctionById = {}; + + if (this.externalFunctions === undefined) { + return result; + } + + for (const buildFunction of this.externalFunctions) { + const fullId = buildFunction.getFullId(); + result[fullId] = buildFunction; + } + return result; + } + + private createBuildStepsFromStepConfig( + stepConfig: Step, + { + buildFunctionById, + buildFunctionGroupById, + }: { + buildFunctionById: BuildFunctionById; + buildFunctionGroupById: BuildFunctionGroupById; + } + ): BuildStep[] { + if (isStepShellStep(stepConfig)) { + return [this.createBuildStepFromShellStepConfig(stepConfig)]; + } else if (isStepFunctionStep(stepConfig)) { + return this.createBuildStepsFromFunctionStepConfig(stepConfig, { + buildFunctionById, + buildFunctionGroupById, + }); + } else { + throw new BuildConfigError( + 'Invalid job step configuration detected. Step must be shell or function step' + ); + } + } + + private createBuildStepFromShellStepConfig(step: ShellStep): BuildStep { + const id = BuildStep.getNewId(step.id); + const displayName = BuildStep.getDisplayName({ id, name: step.name, command: step.run }); + const outputs = + step.outputs && this.createBuildStepOutputsFromDefinition(step.outputs, displayName); + return new BuildStep(this.ctx, { + id, + outputs, + name: step.name, + displayName, + workingDirectory: step.working_directory, + shell: step.shell, + command: step.run, + env: step.env, + ifCondition: step.if, + }); + } + + private createBuildStepsFromFunctionStepConfig( + step: FunctionStep, + { + buildFunctionById, + buildFunctionGroupById, + }: { + buildFunctionById: BuildFunctionById; + buildFunctionGroupById: BuildFunctionGroupById; + } + ): BuildStep[] { + const functionId = step.uses; + const maybeFunctionGroup = buildFunctionGroupById[functionId]; + if (maybeFunctionGroup) { + // TODO: allow to set id, name, working_directory, shell, env and if for function groups + return maybeFunctionGroup.createBuildStepsFromFunctionGroupCall(this.ctx, { + callInputs: step.with, + }); + } + + const buildFunction = buildFunctionById[functionId]; + assert(buildFunction, 'function ID must be ID of function or function group'); + + return [ + buildFunction.createBuildStepFromFunctionCall(this.ctx, { + id: step.id, + name: step.name, + callInputs: step.with, + workingDirectory: step.working_directory, + shell: step.shell, + env: step.env, + ifCondition: step.if, + }), + ]; + } + + private createBuildStepOutputsFromDefinition( + stepOutputs: Required['outputs'], + stepDisplayName: string + ): BuildStepOutput[] { + return stepOutputs.map( + (entry) => + new BuildStepOutput(this.ctx, { + id: entry.name, + stepDisplayName, + required: entry.required ?? true, + }) + ); + } + + private static validateAllFunctionsExist( + steps: Step[], + { + externalFunctionIds, + externalFunctionGroupIds, + }: { + externalFunctionIds: string[]; + externalFunctionGroupIds: string[]; + } + ): void { + const calledFunctionsOrFunctionGroupsSet = new Set(); + for (const step of steps) { + if (step.uses) { + calledFunctionsOrFunctionGroupsSet.add(step.uses); + } + } + const calledFunctionsOrFunctionGroup = Array.from(calledFunctionsOrFunctionGroupsSet); + const externalFunctionIdsSet = new Set(externalFunctionIds); + const externalFunctionGroupsIdsSet = new Set(externalFunctionGroupIds); + const nonExistentFunctionsOrFunctionGroups = calledFunctionsOrFunctionGroup.filter( + (calledFunctionOrFunctionGroup) => { + return ( + !externalFunctionIdsSet.has(calledFunctionOrFunctionGroup) && + !externalFunctionGroupsIdsSet.has(calledFunctionOrFunctionGroup) + ); + } + ); + if (nonExistentFunctionsOrFunctionGroups.length > 0) { + throw new BuildConfigError( + `Calling non-existent functions: ${nonExistentFunctionsOrFunctionGroups + .map((f) => `"${f}"`) + .join(', ')}.` + ); + } + } +} diff --git a/packages/steps/src/__tests__/AbstractConfigParser-test.ts b/packages/steps/src/__tests__/AbstractConfigParser-test.ts new file mode 100644 index 0000000000..df1e7ecda2 --- /dev/null +++ b/packages/steps/src/__tests__/AbstractConfigParser-test.ts @@ -0,0 +1,85 @@ +import path from 'path'; +import url from 'url'; + +import { StepsConfigParser } from '../StepsConfigParser.js'; +import { BuildFunction } from '../BuildFunction.js'; +import { BuildConfigParser } from '../BuildConfigParser.js'; +import { BuildStep } from '../BuildStep.js'; + +import { createGlobalContextMock } from './utils/context.js'; +import { UUID_REGEX } from './utils/uuid.js'; + +const __dirname = url.fileURLToPath(new URL('.', import.meta.url)); + +describe('Publish Update job', () => { + it('parses job with steps and build config to the same workflow', async () => { + const ctx = createGlobalContextMock(); + const externalFunctions = [ + new BuildFunction({ + id: 'checkout', + namespace: 'eas', + fn: () => { + console.log('checkout'); + }, + }), + new BuildFunction({ + id: 'use_npm_token', + namespace: 'eas', + fn: () => { + console.log('use_npm_token'); + }, + }), + new BuildFunction({ + id: 'install_node_modules', + namespace: 'eas', + fn: () => { + console.log('install_node_modules'); + }, + }), + ]; + const stepsParser = new StepsConfigParser(ctx, { + steps: [ + { + uses: 'eas/checkout', + }, + { + uses: 'eas/use_npm_token', + }, + { + uses: 'eas/install_node_modules', + }, + { + name: 'Publish update', + run: 'EXPO_TOKEN="${ eas.job.secrets.robotAccessToken }" npx -y eas-cli@latest update --auto', + }, + ], + externalFunctions, + }); + const stepsResult = await stepsParser.parseAsync(); + const configParser = new BuildConfigParser(ctx, { + configPath: path.join(__dirname, './fixtures/publish-update-job-as-config.yml'), + externalFunctions, + }); + const configResult = await configParser.parseAsync(); + expect(stepsResult.buildSteps.length).toEqual(configResult.buildSteps.length); + for (let i = 0; i < stepsResult.buildSteps.length; i++) { + assertStepsAreMatching(stepsResult.buildSteps[i], configResult.buildSteps[i]); + } + }); +}); + +function assertStepsAreMatching(step1: BuildStep, step2: BuildStep): void { + expect(step1.id).toMatch(UUID_REGEX); + expect(step2.id).toMatch(UUID_REGEX); + expect(step1.name).toEqual(step2.name); + if (step1.command) { + expect(step1.displayName).toEqual(step2.displayName); + } else { + expect(step1.displayName).toMatch(UUID_REGEX); + expect(step2.displayName).toMatch(UUID_REGEX); + } + expect(step1.inputs).toStrictEqual(step2.inputs); + expect(step1.outputById).toStrictEqual(step2.outputById); + expect(step1.command?.trim()).toEqual(step2.command?.trim()); + expect(step1.fn).toEqual(step2.fn); +} diff --git a/packages/steps/src/__tests__/BuildConfig-test.ts b/packages/steps/src/__tests__/BuildConfig-test.ts new file mode 100644 index 0000000000..e5e03bf1ff --- /dev/null +++ b/packages/steps/src/__tests__/BuildConfig-test.ts @@ -0,0 +1,1100 @@ +import assert from 'assert'; +import path from 'path'; +import url from 'url'; + +import { + BuildStepBareCommandRun, + BuildStepBareFunctionOrFunctionGroupCall, + BuildStepCommandRun, + BuildStepFunctionCall, + isBuildStepBareCommandRun, + isBuildStepBareFunctionOrFunctionGroupCall, + isBuildStepCommandRun, + isBuildStepFunctionCall, + readRawBuildConfigAsync, + readAndValidateBuildConfigFromPathAsync, + validateConfig, + BuildFunctionsConfigFileSchema, + BuildConfigSchema, + validateAllFunctionsExist, + BuildConfig, + mergeConfigWithImportedFunctions, + BuildFunctions, + readAndValidateBuildFunctionsConfigFileAsync, +} from '../BuildConfig.js'; +import { BuildConfigError, BuildConfigYAMLError } from '../errors.js'; + +import { getError, getErrorAsync } from './utils/error.js'; + +const __dirname = url.fileURLToPath(new URL('.', import.meta.url)); + +describe(readAndValidateBuildConfigFromPathAsync, () => { + test('valid custom build config', async () => { + const config = await readAndValidateBuildConfigFromPathAsync( + path.join(__dirname, './fixtures/build.yml'), + { + externalFunctionIds: [], + } + ); + expect(typeof config).toBe('object'); + expect(config.build.name).toBe('Foobar'); + assert(isBuildStepBareCommandRun(config.build.steps[0])); + expect(config.build.steps[0].run).toBe('echo "Hi!"'); + assert(isBuildStepCommandRun(config.build.steps[2])); + expect(config.build.steps[2].run.env).toMatchObject({ FOO: 'bar', BAR: 'baz' }); + assert(isBuildStepCommandRun(config.build.steps[5])); + expect(config.build.steps[5].run.if).toBe('${ always() }'); + }); + test('valid custom build config with imports', async () => { + const config = await readAndValidateBuildConfigFromPathAsync( + path.join(__dirname, './fixtures/build-with-import.yml'), + { + externalFunctionIds: [], + } + ); + expect(typeof config).toBe('object'); + expect(config.build.name).toBe('Import!'); + assert(isBuildStepFunctionCall(config.build.steps[0])); + expect(config.build.steps[0]).toMatchObject({ + say_hi: { + inputs: { name: 'Dominik' }, + env: { + ENV1: 'value1', + ENV2: 'value2', + }, + }, + }); + assert(isBuildStepBareFunctionOrFunctionGroupCall(config.build.steps[1])); + expect(config.build.steps[1]).toBe('say_hi_wojtek'); + expect(config.functions?.say_hi).toBeDefined(); + expect(config.functions?.say_hi_wojtek).toBeDefined(); + }); + test('import cycle does not result in infinite loop', async () => { + const config = await readAndValidateBuildConfigFromPathAsync( + path.join(__dirname, './fixtures/build-with-import-cycle.yml'), + { + externalFunctionIds: [], + } + ); + expect(typeof config).toBe('object'); + expect(config.build.name).toBe('Import!'); + assert(isBuildStepFunctionCall(config.build.steps[0])); + expect(config.build.steps[0]).toMatchObject({ say_hi: expect.any(Object) }); + expect(config.functions?.say_hi).toBeDefined(); + }); + test('function precedence', async () => { + const config = await readAndValidateBuildConfigFromPathAsync( + path.join(__dirname, './fixtures/build-with-import.yml'), + { + externalFunctionIds: [], + } + ); + expect(typeof config).toBe('object'); + expect(config.functions?.say_hi_wojtek).toBeDefined(); + expect(config.functions?.say_hi_wojtek.name).toBe('Hi, Wojtek!'); + expect(config.functions?.say_hi_wojtek.command).toBe('echo "Hi, Wojtek!"'); + }); +}); + +describe(readAndValidateBuildFunctionsConfigFileAsync, () => { + test('valid functions config', async () => { + const config = await readAndValidateBuildFunctionsConfigFileAsync( + path.join(__dirname, './fixtures/functions-file-1.yml') + ); + expect(typeof config).toBe('object'); + expect(config.configFilesToImport?.[0]).toBe('functions-file-2.yml'); + expect(config.functions?.say_hi).toBeDefined(); + }); + test('valid functions with platform property config', async () => { + const config = await readAndValidateBuildFunctionsConfigFileAsync( + path.join(__dirname, './fixtures/functions-with-platforms-property.yml') + ); + expect(typeof config).toBe('object'); + expect(config.functions?.say_hi_linux_and_darwin).toBeDefined(); + }); + test('invalid functions config', async () => { + const error = await getErrorAsync(async () => { + return await readAndValidateBuildFunctionsConfigFileAsync( + path.join(__dirname, './fixtures/invalid-functions.yml') + ); + }); + expect(error).toBeInstanceOf(BuildConfigError); + expect(error.message).toMatch( + /"functions.say_hi.inputs\[0\].allowedValues\[1\]" must be a boolean/ + ); + expect(error.message).toMatch( + /"functions.say_hi.inputs\[1\].defaultValue" with value "\${ wrong.job.platform }" fails to match the context or output reference regex pattern/ + ); + }); +}); + +describe(readRawBuildConfigAsync, () => { + test('non-existent file', async () => { + await expect(readRawBuildConfigAsync('/fake/path/a.yml')).rejects.toThrowError( + /no such file or directory/ + ); + }); + test('invalid yaml file', async () => { + const error = await getErrorAsync(async () => { + return await readRawBuildConfigAsync(path.join(__dirname, './fixtures/invalid.yml')); + }); + expect(error).toBeInstanceOf(BuildConfigYAMLError); + expect(error.message).toMatch(/Map keys must be unique at line/); + }); + + test('valid yaml file', async () => { + const rawConfig = await readRawBuildConfigAsync(path.join(__dirname, './fixtures/build.yml')); + expect(typeof rawConfig).toBe('object'); + }); +}); + +describe(validateConfig, () => { + test('can throw BuildConfigError', () => { + const buildConfig = {}; + + expect(() => { + validateConfig(BuildConfigSchema, buildConfig); + }).toThrowError(BuildConfigError); + }); + + describe('with BuildConfigSchema', () => { + describe('import', () => { + test('non-yaml files', () => { + const buildConfig = { + configFilesToImport: ['a.apk', 'b.ipa'], + build: { + steps: [{ run: 'echo 123' }], + }, + }; + + const error = getError(() => { + validateConfig(BuildConfigSchema, buildConfig); + }); + expect(error.message).toMatch( + /"configFilesToImport\[0\]" with value ".*" fails to match the required pattern/ + ); + expect(error.message).toMatch( + /"configFilesToImport\[1\]" with value ".*" fails to match the required pattern/ + ); + }); + test('yaml files', () => { + const buildConfig = { + configFilesToImport: ['a.yaml', 'b.yml'], + build: { + steps: [{ run: 'echo 123' }], + }, + }; + + expect(() => { + validateConfig(BuildConfigSchema, buildConfig); + }).not.toThrowError(); + }); + }); + + describe('build.steps', () => { + test('inline command', () => { + const buildConfig = { + build: { + steps: [ + { + run: 'echo 123', + }, + ], + }, + }; + + expect(() => { + validateConfig(BuildConfigSchema, buildConfig); + }).not.toThrowError(); + }); + + describe('commands', () => { + test('command is required', () => { + const buildConfig = { + build: { + steps: [ + { + run: {}, + }, + ], + }, + }; + + expect(() => { + validateConfig(BuildConfigSchema, buildConfig); + }).toThrowError(/".*\.command" is required/); + }); + test('non-existent fields', () => { + const buildConfig = { + build: { + steps: [ + { + run: { + command: 'echo 123', + blah: '123', + }, + }, + ], + }, + }; + + expect(() => { + validateConfig(BuildConfigSchema, buildConfig); + }).toThrowError(/".*\.blah" is not allowed/); + }); + test('invalid env structure', () => { + const buildConfig = { + build: { + steps: [ + { + run: { + command: 'echo 123', + env: { + ENV1: { + invalid: '123', + }, + }, + }, + }, + ], + }, + }; + + expect(() => { + validateConfig(BuildConfigSchema, buildConfig); + }).toThrowError(/"build.steps\[0\].run.env.ENV1" must be a string/); + }); + test('invalid env type', () => { + const buildConfig = { + build: { + steps: [ + { + run: { + command: 'echo 123', + env: { + ENV1: true, + }, + }, + }, + ], + }, + }; + + expect(() => { + validateConfig(BuildConfigSchema, buildConfig); + }).toThrowError(/"build.steps\[0\].run.env.ENV1" must be a string/); + }); + test('valid timeout_minutes', () => { + const buildConfig = { + build: { + steps: [ + { + run: { + command: 'echo 123', + timeout_minutes: 5, + }, + }, + ], + }, + }; + + expect(() => { + validateConfig(BuildConfigSchema, buildConfig); + }).not.toThrowError(); + }); + test('invalid timeout_minutes (negative)', () => { + const buildConfig = { + build: { + steps: [ + { + run: { + command: 'echo 123', + timeout_minutes: -5, + }, + }, + ], + }, + }; + + expect(() => { + validateConfig(BuildConfigSchema, buildConfig); + }).toThrowError(/"build.steps\[0\].run.timeout_minutes" must be a positive number/); + }); + test('invalid timeout_minutes (zero)', () => { + const buildConfig = { + build: { + steps: [ + { + run: { + command: 'echo 123', + timeout_minutes: 0, + }, + }, + ], + }, + }; + + expect(() => { + validateConfig(BuildConfigSchema, buildConfig); + }).toThrowError(/"build.steps\[0\].run.timeout_minutes" must be a positive number/); + }); + test('valid command', () => { + const buildConfig = { + build: { + steps: [ + { + run: { + command: 'echo 123', + env: { + FOO: 'bar', + BAZ: 'baz', + }, + if: '${ always() }', + }, + }, + ], + }, + }; + + expect(() => { + validateConfig(BuildConfigSchema, buildConfig); + }).not.toThrowError(); + }); + }); + + describe('function calls', () => { + test('bare call', () => { + const buildConfig = { + build: { + steps: ['say_hi'], + }, + functions: { + say_hi: { + command: 'echo Hi!', + }, + }, + }; + + expect(() => { + validateConfig(BuildConfigSchema, buildConfig); + }).not.toThrowError(); + }); + test('non-existent fields', () => { + const buildConfig = { + build: { + steps: [ + { + say_hi: { + blah: '123', + }, + }, + ], + }, + functions: { + say_hi: { + command: 'echo Hi!', + }, + }, + }; + + expect(() => { + validateConfig(BuildConfigSchema, buildConfig); + }).toThrowError(/".*\.blah" is not allowed/); + }); + test('command is not allowed', () => { + const buildConfig = { + build: { + steps: [ + { + say_hi: { + command: 'echo 123', + }, + }, + ], + }, + functions: { + say_hi: { + command: 'echo Hi!', + }, + }, + }; + + expect(() => { + validateConfig(BuildConfigSchema, buildConfig); + }).toThrowError(/".*\.command" is not allowed/); + }); + test('call with inputs', () => { + const buildConfig = { + build: { + steps: [ + { + say_hi: { + inputs: { + name: 'Dominik', + }, + }, + }, + ], + }, + functions: { + say_hi: { + command: 'echo "Hi, ${ inputs.name }!"', + }, + }, + }; + + expect(() => { + validateConfig(BuildConfigSchema, buildConfig); + }).not.toThrowError(); + }); + test('call with env', () => { + const buildConfig = { + build: { + steps: [ + { + say_hi: { + env: { + ENV1: 'value1', + ENV2: 'value2', + }, + inputs: { + name: 'Dominik', + }, + }, + }, + ], + }, + functions: { + say_hi: { + command: 'echo "Hi, ${ inputs.name }!"', + }, + }, + }; + + expect(() => { + validateConfig(BuildConfigSchema, buildConfig); + }).not.toThrowError(); + }); + test('call with if statement', () => { + const buildConfig = { + build: { + steps: [ + { + say_hi: { + if: '${ success() }', + inputs: { + name: 'Dominik', + }, + }, + }, + ], + }, + functions: { + say_hi: { + command: 'echo "Hi, ${ inputs.name }!"', + }, + }, + }; + + expect(() => { + validateConfig(BuildConfigSchema, buildConfig); + }).not.toThrowError(); + }); + test('invalid env type', () => { + const buildConfig = { + build: { + steps: [ + { + say_hi: { + env: { + ENV1: true, + }, + }, + }, + ], + }, + functions: { + say_hi: { + command: 'echo "Hi!"', + }, + }, + }; + + expect(() => { + validateConfig(BuildConfigSchema, buildConfig); + }).toThrowError(/"build.steps\[0\].say_hi.env.ENV1" must be a string/); + }); + test('invalid env structure', () => { + const buildConfig = { + build: { + steps: [ + { + say_hi: { + env: { + ENV1: { + invalid: '123', + }, + }, + }, + }, + ], + }, + functions: { + say_hi: { + command: 'echo "Hi!"', + }, + }, + }; + + expect(() => { + validateConfig(BuildConfigSchema, buildConfig); + }).toThrowError(/"build.steps\[0\].say_hi.env.ENV1" must be a string/); + }); + test('call with inputs boolean', () => { + const buildConfig = { + build: { + steps: [ + { + test_boolean: { + inputs: { + boolean: true, + boolean2: '${ eas.job.booleanValue }', + }, + }, + }, + ], + }, + functions: { + test_boolean: { + inputs: [ + { + name: 'boolean', + type: 'boolean', + }, + { + name: 'boolean2', + type: 'boolean', + defaultValue: '${ eas.job.booleanValue }', + }, + ], + command: 'echo "${ inputs.boolean }!"', + }, + }, + }; + + expect(() => { + validateConfig(BuildConfigSchema, buildConfig); + }).not.toThrowError(); + }); + test('at most one function call per step', () => { + const buildConfig = { + build: { + steps: [ + { + say_hi: { + inputs: { + name: 'Dominik', + }, + }, + say_hello: { + inputs: { + name: false, + }, + }, + }, + ], + }, + functions: { + say_hi: { + command: 'echo "Hi, ${ inputs.name }!"', + }, + say_hello: { + command: 'echo "Hello, ${ inputs.name }!"', + }, + }, + }; + + expect(() => { + validateConfig(BuildConfigSchema, buildConfig); + }).toThrowError(); + }); + }); + }); + + describe('functions', () => { + test('command is required', () => { + const buildConfig = { + build: { + steps: [], + }, + functions: { + say_hi: {}, + }, + }; + + expect(() => { + validateConfig(BuildConfigSchema, buildConfig); + }).toThrowError(/".*\.say_hi" must contain at least one of \[command, path\]/); + }); + test('"run" is not allowed for function name', () => { + const buildConfig = { + build: { + steps: [], + }, + functions: { + run: {}, + }, + }; + + expect(() => { + validateConfig(BuildConfigSchema, buildConfig); + }).toThrowError(/"functions.run" is not allowed/); + }); + test('function IDs must be alphanumeric (including underscore and dash)', () => { + const buildConfig = { + build: { + steps: [], + }, + functions: { + foo: {}, + upload_artifact: {}, + 'build-project': {}, + 'eas/download_project': {}, + '!@#$': {}, + }, + }; + + const error = getError(() => { + validateConfig(BuildConfigSchema, buildConfig); + }); + expect(error.message).toMatch(/"functions\.eas\/download_project" is not allowed/); + expect(error.message).toMatch(/"functions\.!@#\$" is not allowed/); + expect(error.message).not.toMatch(/"functions\.foo" is not allowed/); + expect(error.message).not.toMatch(/"functions\.upload_artifact" is not allowed/); + expect(error.message).not.toMatch(/"functions\.build-project" is not allowed/); + }); + test('invalid default, allowed values and type for function inputs', () => { + const buildConfig = { + build: { + steps: ['abc'], + }, + functions: { + abc: { + inputs: [ + { + name: 'i1', + default_value: 1, + }, + { + name: 'i2', + default_value: 'hi', + allowed_values: ['bye'], + }, + { + name: 'i3', + default_value: 'hhh', + type: 'string', + allowed_values: [1, 2], + }, + { + name: 'i4', + default_value: 'hello', + type: 'wrong', + }, + { + name: 'i5', + default_value: true, + type: 'number', + }, + { + name: 'i6', + default_value: 'abc', + type: 'boolean', + }, + { + name: 'i7', + default_value: true, + type: 'json', + }, + ], + command: 'echo "${ inputs.i1 } ${ inputs.i2 }"', + }, + }, + }; + + const error = getError(() => { + validateConfig(BuildConfigSchema, buildConfig); + }); + expect(error.message).toMatch(/"functions.abc.inputs\[0\].defaultValue" must be a string/); + expect(error.message).toMatch( + /"functions.abc.inputs\[1\].defaultValue" must be one of allowed values/ + ); + expect(error.message).toMatch( + /"functions.abc.inputs\[2\].allowedValues\[0\]" must be a string/ + ); + expect(error.message).toMatch( + /"functions.abc.inputs\[2\].allowedValues\[1\]" must be a string/ + ); + expect(error.message).toMatch( + /"functions.abc.inputs\[3\].allowedValueType" must be one of \[string, boolean, number, json\]/ + ); + expect(error.message).toMatch(/"functions.abc.inputs\[4\].defaultValue" must be a number/); + expect(error.message).toMatch( + /"functions.abc.inputs\[5\].defaultValue" with value "abc" fails to match the context or output reference regex pattern pattern/ + ); + expect(error.message).toMatch(/"functions.abc.inputs\[6\].defaultValue" must be a object/); + }); + test('valid default and allowed values for function inputs', () => { + const buildConfig = { + build: { + steps: ['abc'], + }, + functions: { + abc: { + inputs: [ + { + name: 'i1', + default_value: '1', + }, + { + name: 'i2', + default_value: '1', + allowed_values: ['1', '2'], + }, + { + name: 'i3', + default_value: true, + allowed_values: [true, false], + type: 'boolean', + }, + { + name: 'i4', + default_value: 1, + type: 'number', + allowed_values: [1, 2], + }, + { + name: 'i5', + default_value: { + a: 1, + b: { + c: [2, 3], + d: false, + e: { + f: 'hi', + }, + }, + }, + type: 'json', + }, + { + name: 'i6', + default_value: '${ eas.job.version.buildNumber }', + type: 'number', + }, + { + name: 'i7', + default_value: '${ steps.stepid.someBoolean }', + type: 'boolean', + }, + ], + command: 'echo "${ inputs.i1 } ${ inputs.i2 } ${ inputs.i3 }"', + }, + }, + }; + + expect(() => { + validateConfig(BuildConfigSchema, buildConfig); + }).not.toThrow(); + }); + + test('valid allowed platforms for function', () => { + const buildConfig = { + build: { + steps: ['abc'], + }, + functions: { + abc: { + supported_platforms: ['linux', 'darwin'], + command: 'echo "abc"', + }, + }, + }; + + expect(() => { + validateConfig(BuildConfigSchema, buildConfig); + }).not.toThrow(); + }); + }); + + test('valid function with path to custom JS/TS function', () => { + const buildConfig = { + build: { + steps: ['abc'], + }, + functions: { + abc: { + path: 'path/to/function', + }, + }, + }; + expect(() => { + validateConfig(BuildConfigSchema, buildConfig); + }).not.toThrow(); + }); + + test('invalid function with both command and path specified', () => { + const buildConfig = { + build: { + steps: ['abc'], + }, + functions: { + abc: { + command: 'echo "abc"', + path: 'path/to/function.js', + }, + }, + }; + const error = getError(() => { + validateConfig(BuildConfigSchema, buildConfig); + }); + expect(error.message).toMatch( + /"functions.abc" contains a conflict between exclusive peers \[command, path\], "command" must not exist simultaneously with \[path\]/ + ); + }); + + test('invalid allowed platforms for function', () => { + const buildConfig = { + build: { + steps: ['abc'], + }, + functions: { + abc: { + supported_platforms: ['invalid'], + command: 'echo "abc"', + }, + }, + }; + + expect(() => { + validateConfig(BuildConfigSchema, buildConfig); + }).toThrow(); + }); + }); + + describe('with BuildFunctionsConfigFileSchema', () => { + test('"build" is not allowed', () => { + const buildFunctionsConfig = { + build: { + steps: ['abc'], + }, + functions: { + abc: { command: 'echo abc' }, + }, + }; + const error = getError(() => { + validateConfig(BuildFunctionsConfigFileSchema, buildFunctionsConfig); + }); + expect(error).toBeInstanceOf(BuildConfigError); + expect(error.message).toBe('"build" is not allowed'); + }); + test('valid config', () => { + const buildFunctionsConfig = { + functions: { + abc: { command: 'echo abc' }, + }, + }; + expect(() => { + validateConfig(BuildFunctionsConfigFileSchema, buildFunctionsConfig); + }).not.toThrow(); + }); + }); +}); + +describe(mergeConfigWithImportedFunctions, () => { + test('merging config with imported functions', () => { + const buildConfig: BuildConfig = { + configFilesToImport: ['func.yaml'], + build: { + steps: ['a', 'b', 'c'], + }, + functions: { + a: { command: 'echo a' }, + }, + }; + const importedFunctions: BuildFunctions = { + b: { command: 'echo b' }, + c: { command: 'echo c' }, + }; + mergeConfigWithImportedFunctions(buildConfig, importedFunctions); + expect(buildConfig.functions?.b).toBe(importedFunctions.b); + expect(buildConfig.functions?.c).toBe(importedFunctions.c); + }); + test('functions from base config shadow the imported ones', () => { + const buildConfig: BuildConfig = { + build: { + steps: ['a', 'b', 'c'], + }, + functions: { + a: { command: 'echo a1' }, + }, + }; + const importedFunctions: BuildFunctions = { + a: { command: 'echo a2' }, + b: { command: 'echo b' }, + c: { command: 'echo c' }, + }; + mergeConfigWithImportedFunctions(buildConfig, importedFunctions); + expect(buildConfig.functions?.a).not.toBe(importedFunctions.a); + expect(buildConfig.functions?.a.command).toBe('echo a1'); + expect(buildConfig.functions?.b).toBe(importedFunctions.b); + expect(buildConfig.functions?.c).toBe(importedFunctions.c); + }); +}); + +describe(validateAllFunctionsExist, () => { + test('non-existent functions', () => { + const buildConfig: BuildConfig = { + build: { + steps: ['say_hi', 'say_hello'], + }, + }; + + expect(() => { + validateAllFunctionsExist(buildConfig, { externalFunctionIds: [] }); + }).toThrowError(/Calling non-existent functions: "say_hi", "say_hello"/); + }); + test('non-existent function groups', () => { + const buildConfig: BuildConfig = { + build: { + steps: ['eas/build'], + }, + }; + + expect(() => { + validateAllFunctionsExist(buildConfig, { externalFunctionIds: [] }); + }).toThrowError(/Calling non-existent functions: "eas\/build"/); + }); + test('non-existent namespaced functions with skipNamespacedFunctionsOrFunctionGroupsCheck = false', () => { + const buildConfig: BuildConfig = { + build: { + steps: ['abc/say_hi', 'abc/say_hello'], + }, + }; + + expect(() => { + validateAllFunctionsExist(buildConfig, { + externalFunctionIds: [], + skipNamespacedFunctionsOrFunctionGroupsCheck: false, + }); + }).toThrowError(/Calling non-existent functions: "abc\/say_hi", "abc\/say_hello"/); + }); + test('non-existent namespaced functions with skipNamespacedFunctionsOrFunctionGroupsCheck = true', () => { + const buildConfig: BuildConfig = { + build: { + steps: ['abc/say_hi', 'abc/say_hello'], + }, + }; + + expect(() => { + validateAllFunctionsExist(buildConfig, { + externalFunctionIds: [], + skipNamespacedFunctionsOrFunctionGroupsCheck: true, + }); + }).not.toThrow(); + }); + test('works with external functions', () => { + const buildConfig: BuildConfig = { + build: { + steps: ['say_hi', 'say_hello'], + }, + }; + + expect(() => { + validateAllFunctionsExist(buildConfig, { + externalFunctionIds: ['say_hi', 'say_hello'], + }); + }).not.toThrowError(); + }); + test('works with external function groups', () => { + const buildConfig: BuildConfig = { + build: { + steps: ['hi'], + }, + }; + + expect(() => { + validateAllFunctionsExist(buildConfig, { + externalFunctionGroupsIds: ['hi'], + }); + }).not.toThrowError(); + }); +}); + +const buildStepCommandRun: BuildStepCommandRun = { + run: { + command: 'echo 123', + }, +}; + +const buildStepBareCommandRun: BuildStepBareCommandRun = { + run: 'echo 123', +}; + +const buildStepFunctionCall: BuildStepFunctionCall = { + say_hi: { + inputs: { + name: 'Dominik', + }, + }, +}; + +const buildStepBareFunctionCall: BuildStepBareFunctionOrFunctionGroupCall = 'say_hi'; + +describe(isBuildStepCommandRun, () => { + it.each([buildStepBareCommandRun, buildStepFunctionCall, buildStepBareFunctionCall])( + 'returns false', + (i) => { + expect(isBuildStepCommandRun(i)).toBe(false); + } + ); + it('returns true', () => { + expect(isBuildStepCommandRun(buildStepCommandRun)).toBe(true); + }); +}); + +describe(isBuildStepBareCommandRun, () => { + it.each([buildStepCommandRun, buildStepFunctionCall, buildStepBareFunctionCall])( + 'returns false', + (i) => { + expect(isBuildStepBareCommandRun(i)).toBe(false); + } + ); + it('returns true', () => { + expect(isBuildStepBareCommandRun(buildStepBareCommandRun)).toBe(true); + }); +}); + +describe(isBuildStepFunctionCall, () => { + it.each([buildStepCommandRun, buildStepBareCommandRun, buildStepBareFunctionCall])( + 'returns false', + (i) => { + expect(isBuildStepFunctionCall(i)).toBe(false); + } + ); + it('returns true', () => { + expect(isBuildStepFunctionCall(buildStepFunctionCall)).toBe(true); + }); +}); + +describe(isBuildStepBareFunctionOrFunctionGroupCall, () => { + it.each([buildStepCommandRun, buildStepBareCommandRun, buildStepFunctionCall])( + 'returns false', + (i) => { + expect(isBuildStepBareFunctionOrFunctionGroupCall(i)).toBe(false); + } + ); + it('returns true', () => { + expect(isBuildStepBareFunctionOrFunctionGroupCall(buildStepBareFunctionCall)).toBe(true); + }); +}); diff --git a/packages/steps/src/__tests__/BuildConfigParser-test.ts b/packages/steps/src/__tests__/BuildConfigParser-test.ts new file mode 100644 index 0000000000..c6279c6ac4 --- /dev/null +++ b/packages/steps/src/__tests__/BuildConfigParser-test.ts @@ -0,0 +1,767 @@ +import path from 'path'; +import url from 'url'; + +import { BuildConfigParser } from '../BuildConfigParser.js'; +import { BuildFunction } from '../BuildFunction.js'; +import { BuildStepFunction } from '../BuildStep.js'; +import { BuildWorkflow } from '../BuildWorkflow.js'; +import { BuildConfigError, BuildStepRuntimeError } from '../errors.js'; +import { BuildRuntimePlatform } from '../BuildRuntimePlatform.js'; +import { BuildStepInputValueTypeName } from '../BuildStepInput.js'; +import { BuildFunctionGroup } from '../BuildFunctionGroup.js'; + +import { createGlobalContextMock } from './utils/context.js'; +import { getError, getErrorAsync } from './utils/error.js'; +import { UUID_REGEX } from './utils/uuid.js'; + +const __dirname = url.fileURLToPath(new URL('.', import.meta.url)); + +describe(BuildConfigParser, () => { + describe('constructor', () => { + it('throws if provided external functions with duplicated IDs', () => { + const ctx = createGlobalContextMock(); + const error = getError(() => { + // eslint-disable-next-line no-new + new BuildConfigParser(ctx, { + configPath: './fake.yml', + externalFunctions: [ + new BuildFunction({ id: 'abc', command: 'echo 123' }), + new BuildFunction({ id: 'abc', command: 'echo 456' }), + ], + }); + }); + expect(error).toBeInstanceOf(BuildConfigError); + expect(error.message).toMatch(/Provided external functions with duplicated IDs/); + }); + + it('throws if provided external function groups with duplicated IDs', () => { + const ctx = createGlobalContextMock(); + const error = getError(() => { + // eslint-disable-next-line no-new + new BuildConfigParser(ctx, { + configPath: './fake.yml', + externalFunctionGroups: [ + new BuildFunctionGroup({ + id: 'abc', + namespace: 'test', + createBuildStepsFromFunctionGroupCall: () => [], + }), + new BuildFunctionGroup({ + id: 'abc', + namespace: 'test', + createBuildStepsFromFunctionGroupCall: () => [], + }), + ], + }); + }); + expect(error).toBeInstanceOf(BuildConfigError); + expect(error.message).toMatch(/Provided external function groups with duplicated IDs/); + }); + + it(`doesn't throw if provided external functions don't have duplicated IDs`, () => { + const ctx = createGlobalContextMock(); + expect(() => { + // eslint-disable-next-line no-new + new BuildConfigParser(ctx, { + configPath: './fake.yml', + externalFunctions: [ + new BuildFunction({ namespace: 'a', id: 'abc', command: 'echo 123' }), + new BuildFunction({ namespace: 'b', id: 'abc', command: 'echo 456' }), + ], + }); + }).not.toThrow(); + }); + + it(`doesn't throw if provided external function groups don't have duplicated IDs`, () => { + const ctx = createGlobalContextMock(); + expect(() => { + // eslint-disable-next-line no-new + new BuildConfigParser(ctx, { + configPath: './fake.yml', + externalFunctionGroups: [ + new BuildFunctionGroup({ + id: 'abc', + namespace: 'test', + createBuildStepsFromFunctionGroupCall: () => [], + }), + new BuildFunctionGroup({ + id: 'abcd', + namespace: 'test', + createBuildStepsFromFunctionGroupCall: () => [], + }), + ], + }); + }).not.toThrow(); + }); + }); + + describe(BuildConfigParser.prototype.parseAsync, () => { + it('returns a BuildWorkflow object', async () => { + const ctx = createGlobalContextMock(); + const parser = new BuildConfigParser(ctx, { + configPath: path.join(__dirname, './fixtures/build.yml'), + }); + const result = await parser.parseAsync(); + expect(result).toBeInstanceOf(BuildWorkflow); + }); + + it('parses steps from the build workflow', async () => { + const ctx = createGlobalContextMock(); + const parser = new BuildConfigParser(ctx, { + configPath: path.join(__dirname, './fixtures/build.yml'), + }); + const workflow = await parser.parseAsync(); + const buildSteps = workflow.buildSteps; + expect(buildSteps.length).toBe(6); + + // - run: echo "Hi!" + const step1 = buildSteps[0]; + expect(step1.id).toMatch(UUID_REGEX); + expect(step1.name).toBeUndefined(); + expect(step1.command).toBe('echo "Hi!"'); + expect(step1.ctx.workingDirectory).toBe(ctx.defaultWorkingDirectory); + expect(step1.shell).toBe('/bin/bash -eo pipefail'); + expect(step1.stepEnvOverrides).toMatchObject({}); + + // - run: + // name: Say HELLO + // command: | + // echo "H" + // echo "E" + // echo "L" + // echo "L" + // echo "O" + const step2 = buildSteps[1]; + expect(step2.id).toMatch(UUID_REGEX); + expect(step2.name).toBe('Say HELLO'); + expect(step2.command).toMatchSnapshot(); + expect(step2.ctx.workingDirectory).toBe(ctx.defaultWorkingDirectory); + expect(step2.shell).toBe('/bin/bash -eo pipefail'); + expect(step2.stepEnvOverrides).toMatchObject({}); + + // - run: + // id: id_2137 + // command: echo "Step with an ID" + // env: + // FOO: bar + // BAR: baz + const step3 = buildSteps[2]; + expect(step3.id).toBe('id_2137'); + expect(step3.name).toBeUndefined(); + expect(step3.command).toBe('echo "Step with an ID"'); + expect(step3.ctx.workingDirectory).toBe(ctx.defaultWorkingDirectory); + expect(step3.shell).toBe('/bin/bash -eo pipefail'); + expect(step3.stepEnvOverrides).toMatchObject({ + FOO: 'bar', + BAR: 'baz', + }); + + // - run: + // name: List files + // working_directory: relative/path/to/files + // command: ls -la + const step4 = buildSteps[3]; + expect(step4.id).toMatch(UUID_REGEX); + expect(step4.name).toBe('List files'); + expect(step4.command).toBe('ls -la'); + expect(step4.ctx.workingDirectory).toBe( + path.join(ctx.defaultWorkingDirectory, 'relative/path/to/files') + ); + expect(step4.shell).toBe('/bin/bash -eo pipefail'); + expect(step4.stepEnvOverrides).toMatchObject({}); + + // - run: + // name: List files in another directory + // working_directory: /home/dsokal + // command: ls -la + const step5 = buildSteps[4]; + expect(step5.id).toMatch(UUID_REGEX); + expect(step5.name).toBe('List files in another directory'); + expect(step5.command).toBe('ls -la'); + expect(step5.ctx.workingDirectory).toBe( + path.join(ctx.projectTargetDirectory, '/home/dsokal') + ); + expect(step5.shell).toBe('/bin/bash -eo pipefail'); + expect(step5.stepEnvOverrides).toMatchObject({}); + + // - run: + // if: ${ always() } + // name: Use non-default shell + // shell: /nib/hsab + // command: echo 123 + const step6 = buildSteps[5]; + expect(step6.id).toMatch(UUID_REGEX); + expect(step6.name).toBe('Use non-default shell'); + expect(step6.command).toBe('echo 123'); + expect(step6.ctx.workingDirectory).toBe(ctx.defaultWorkingDirectory); + expect(step6.shell).toBe('/nib/hsab'); + expect(step6.stepEnvOverrides).toMatchObject({}); + expect(step6.ifCondition).toBe('${ always() }'); + }); + + it('parses inputs', async () => { + const ctx = createGlobalContextMock(); + const parser = new BuildConfigParser(ctx, { + configPath: path.join(__dirname, './fixtures/inputs.yml'), + }); + const workflow = await parser.parseAsync(); + const buildSteps = workflow.buildSteps; + expect(buildSteps.length).toBe(1); + + // - run: + // name: Say HI + // inputs: + // name: Dominik Sokal + // country: Poland + // boolean_value: true + // number_value: 123 + // json_value: + // property1: value1 + // property2: + // - value2 + // - value3: + // property3: value4 + // command: echo "Hi, ${ inputs.name }, ${ inputs.boolean_value }!" + const step1 = buildSteps[0]; + expect(step1.id).toMatch(UUID_REGEX); + expect(step1.name).toBe('Say HI'); + expect(step1.command).toBe('echo "Hi, ${ inputs.name }, ${ inputs.boolean_value }!"'); + expect(step1.ctx.workingDirectory).toBe(ctx.defaultWorkingDirectory); + expect(step1.shell).toBe('/bin/bash -eo pipefail'); + expect(step1.inputs).toBeDefined(); + expect(step1.inputs?.[0].id).toBe('name'); + expect( + step1.inputs?.[0].getValue({ interpolationContext: ctx.getInterpolationContext() }) + ).toBe('Dominik Sokal'); + expect(step1.inputs?.[0].allowedValueTypeName).toBe(BuildStepInputValueTypeName.STRING); + expect(step1.inputs?.[1].id).toBe('country'); + expect( + step1.inputs?.[1].getValue({ interpolationContext: ctx.getInterpolationContext() }) + ).toBe('Poland'); + expect(step1.inputs?.[1].allowedValueTypeName).toBe(BuildStepInputValueTypeName.STRING); + expect(step1.inputs?.[2].id).toBe('boolean_value'); + expect( + step1.inputs?.[2].getValue({ interpolationContext: ctx.getInterpolationContext() }) + ).toBe(true); + expect(step1.inputs?.[2].allowedValueTypeName).toBe(BuildStepInputValueTypeName.BOOLEAN); + expect(step1.inputs?.[3].id).toBe('number_value'); + expect( + step1.inputs?.[3].getValue({ interpolationContext: ctx.getInterpolationContext() }) + ).toBe(123); + expect(step1.inputs?.[3].allowedValueTypeName).toBe(BuildStepInputValueTypeName.NUMBER); + expect(step1.inputs?.[4].id).toBe('json_value'); + expect( + step1.inputs?.[4].getValue({ interpolationContext: ctx.getInterpolationContext() }) + ).toMatchObject({ + property1: 'value1', + property2: ['value2', { value3: { property3: 'value4' } }], + }); + expect(step1.inputs?.[4].allowedValueTypeName).toBe(BuildStepInputValueTypeName.JSON); + }); + + it('parses outputs', async () => { + const ctx = createGlobalContextMock(); + const parser = new BuildConfigParser(ctx, { + configPath: path.join(__dirname, './fixtures/outputs.yml'), + }); + const workflow = await parser.parseAsync(); + const buildSteps = workflow.buildSteps; + expect(buildSteps.length).toBe(2); + + // - run: + // outputs: [first_name, last_name] + // command: | + // set-output first_name Brent + // set-output last_name Vatne + const step1 = buildSteps[0]; + expect(step1.id).toMatch(UUID_REGEX); + expect(step1.name).toBeUndefined(); + expect(step1.command).toMatchSnapshot(); + expect(step1.ctx.workingDirectory).toBe(ctx.defaultWorkingDirectory); + expect(step1.shell).toBe('/bin/bash -eo pipefail'); + const { first_name, last_name } = step1.outputById; + expect(first_name.id).toBe('first_name'); + expect(first_name.required).toBe(true); + expect(last_name.id).toBe('last_name'); + expect(last_name.required).toBe(true); + + // - run: + // outputs: + // - name: first_name + // required: true + // - name: middle_name + // required: false + // - name: last_name + // - nickname + // command: | + // set-output first_name Dominik + // set-output last_name Sokal + // set-output nickname dsokal + const step2 = buildSteps[1]; + expect(step2.id).toMatch(UUID_REGEX); + expect(step2.name).toBeUndefined(); + expect(step2.command).toMatchSnapshot(); + expect(step2.ctx.workingDirectory).toBe(ctx.defaultWorkingDirectory); + expect(step2.shell).toBe('/bin/bash -eo pipefail'); + const step2Outputs = step2.outputById; + expect(step2Outputs.first_name.id).toBe('first_name'); + expect(step2Outputs.first_name.required).toBe(true); + expect(step2Outputs.middle_name.id).toBe('middle_name'); + expect(step2Outputs.middle_name.required).toBe(false); + expect(step2Outputs.last_name.id).toBe('last_name'); + expect(step2Outputs.last_name.required).toBe(true); + expect(step2Outputs.nickname.id).toBe('nickname'); + expect(step2Outputs.nickname.required).toBe(true); + }); + + it('parses functions and function calls', async () => { + const ctx = createGlobalContextMock(); + const parser = new BuildConfigParser(ctx, { + configPath: path.join(__dirname, './fixtures/functions.yml'), + }); + const workflow = await parser.parseAsync(); + + const { buildSteps } = workflow; + expect(buildSteps.length).toBe(7); + + // - say_hi: + // env: + // ENV1: value1 + // ENV2: value2 + // inputs: + // name: Dominik + // buildNumber: ${ eas.job.version.buildNumber } + // json_input: + // property1: value1 + // property2: + // - aaa + // - bbb + const step1 = buildSteps[0]; + expect(step1.id).toMatch(UUID_REGEX); + expect(step1.name).toBe('Hi!'); + expect(step1.command).toBe('echo "Hi, ${ inputs.name }!"'); + expect(step1.ctx.workingDirectory).toBe(ctx.defaultWorkingDirectory); + expect(step1.shell).toBe('/bin/bash -eo pipefail'); + expect(step1.inputs?.[0].id).toBe('name'); + expect( + step1.inputs?.[0].getValue({ interpolationContext: ctx.getInterpolationContext() }) + ).toBe('Dominik'); + expect(step1.inputs?.[0].allowedValueTypeName).toBe(BuildStepInputValueTypeName.STRING); + expect(step1.inputs?.[1].id).toBe('build_number'); + expect(step1.inputs?.[1].rawValue).toBe('${ eas.job.version.buildNumber }'); + expect(step1.inputs?.[1].allowedValueTypeName).toBe(BuildStepInputValueTypeName.NUMBER); + expect(step1.inputs?.[2].id).toBe('json_input'); + expect( + step1.inputs?.[2].getValue({ interpolationContext: ctx.getInterpolationContext() }) + ).toMatchObject({ + property1: 'value1', + property2: ['aaa', 'bbb'], + }); + expect(step1.inputs?.[2].allowedValueTypeName).toBe(BuildStepInputValueTypeName.JSON); + expect(step1.stepEnvOverrides).toMatchObject({ + ENV1: 'value1', + ENV2: 'value2', + }); + + // - say_hi: + // name: Hi, Szymon! + // inputs: + // name: Szymon + // build_number: 122 + const step2 = buildSteps[1]; + expect(step2.id).toMatch(UUID_REGEX); + expect(step2.name).toBe('Hi, Szymon!'); + expect(step2.command).toBe('echo "Hi, ${ inputs.name }!"'); + expect(step2.ctx.workingDirectory).toBe(ctx.defaultWorkingDirectory); + expect(step2.shell).toBe('/bin/bash -eo pipefail'); + expect(step2.inputs?.[0].id).toBe('name'); + expect( + step2.inputs?.[0].getValue({ interpolationContext: ctx.getInterpolationContext() }) + ).toBe('Szymon'); + expect(step2.inputs?.[0].allowedValueTypeName).toBe(BuildStepInputValueTypeName.STRING); + expect(step2.inputs?.[1].id).toBe('build_number'); + expect( + step2.inputs?.[1].getValue({ interpolationContext: ctx.getInterpolationContext() }) + ).toBe(122); + expect(step2.inputs?.[1].allowedValueTypeName).toBe(BuildStepInputValueTypeName.NUMBER); + expect(step2.inputs?.[2].id).toBe('json_input'); + expect( + step2.inputs?.[2].getValue({ interpolationContext: ctx.getInterpolationContext() }) + ).toMatchObject({ + property1: 'value1', + property2: ['value2', { value3: { property3: 'value4' } }], + }); + expect(step2.inputs?.[2].allowedValueTypeName).toBe(BuildStepInputValueTypeName.JSON); + expect(step2.stepEnvOverrides).toMatchObject({}); + + // - say_hi_wojtek + const step3 = buildSteps[2]; + expect(step3.id).toMatch(UUID_REGEX); + expect(step3.name).toBe('Hi, Wojtek!'); + expect(step3.command).toBe('echo "Hi, Wojtek!"'); + expect(step3.ctx.workingDirectory).toBe(ctx.defaultWorkingDirectory); + expect(step3.shell).toBe('/bin/bash -eo pipefail'); + expect(step3.stepEnvOverrides).toMatchObject({}); + + // - random: + // id: random_number + const step4 = buildSteps[3]; + expect(step4.id).toMatch('random_number'); + expect(step4.name).toBe('Generate random number'); + expect(step4.command).toBe('set-output value 6'); + expect(step4.ctx.workingDirectory).toBe(ctx.defaultWorkingDirectory); + expect(step4.shell).toBe('/bin/bash -eo pipefail'); + const { value } = step4.outputById; + expect(value.id).toBe('value'); + expect(value.required).toBe(true); + expect(step4.stepEnvOverrides).toMatchObject({}); + + // - print: + // inputs: + // value: ${ steps.random_number.value } + const step5 = buildSteps[4]; + expect(step5.id).toMatch(UUID_REGEX); + expect(step5.name).toBe(undefined); + expect(step5.command).toBe('echo "${ inputs.value }"'); + expect(step5.ctx.workingDirectory).toBe(ctx.defaultWorkingDirectory); + expect(step5.shell).toBe('/bin/bash -eo pipefail'); + expect(step5.inputs?.[0].id).toBe('value'); + expect(step5.inputs?.[0].required).toBe(true); + expect(step5.inputs?.[0].allowedValueTypeName).toBe(BuildStepInputValueTypeName.STRING); + expect(step5.stepEnvOverrides).toMatchObject({}); + + // - say_hi_2: + // inputs: + // greeting: Hello + // num: 123 + const step6 = buildSteps[5]; + expect(step6.id).toMatch(UUID_REGEX); + expect(step6.name).toBe('Hi!'); + expect(step6.command).toBe('echo "${ inputs.greeting }, ${ inputs.name }!"'); + expect(step6.ctx.workingDirectory).toBe(ctx.defaultWorkingDirectory); + expect(step6.shell).toBe('/bin/bash -eo pipefail'); + expect(step6.supportedRuntimePlatforms).toEqual([ + BuildRuntimePlatform.DARWIN, + BuildRuntimePlatform.LINUX, + ]); + expect(step6.inputs?.[0].id).toBe('greeting'); + expect(step6.inputs?.[0].required).toBe(true); + expect(step6.inputs?.[0].defaultValue).toBe('Hi'); + expect(step6.inputs?.[0].allowedValues).toEqual(['Hi', 'Hello']); + expect(step6.inputs?.[0].allowedValueTypeName).toBe(BuildStepInputValueTypeName.STRING); + expect(step6.inputs?.[1].id).toBe('name'); + expect(step6.inputs?.[1].required).toBe(true); + expect(step6.inputs?.[1].defaultValue).toBe('Brent'); + expect(step6.inputs?.[1].allowedValues).toEqual(undefined); + expect(step6.inputs?.[1].allowedValueTypeName).toBe(BuildStepInputValueTypeName.STRING); + expect(step6.inputs?.[2].id).toBe('test'); + expect(step6.inputs?.[2].required).toBe(true); + expect(step6.inputs?.[2].defaultValue).toBe(false); + expect(step6.inputs?.[2].allowedValues).toEqual([false, true]); + expect(step6.inputs?.[2].allowedValueTypeName).toBe(BuildStepInputValueTypeName.BOOLEAN); + expect(step6.inputs?.[3].id).toBe('number'); + expect(step6.inputs?.[3].required).toBe(true); + expect(step6.inputs?.[3].defaultValue).toBe(undefined); + expect(step6.inputs?.[3].allowedValues).toEqual(undefined); + expect(step6.inputs?.[3].allowedValueTypeName).toBe(BuildStepInputValueTypeName.NUMBER); + expect(step6.stepEnvOverrides).toMatchObject({}); + + const { buildFunctions } = workflow; + expect(Object.keys(buildFunctions).length).toBe(6); + + // say_hi: + // name: Hi! + // inputs: + // - name + // - name: build_number + // type: number + // - name: json_input + // type: json + // default_value: + // property1: value1 + // property2: + // - value2 + // - value3: + // property3: value4 + // command: echo "Hi, ${ inputs.name }!" + const function1 = buildFunctions.say_hi; + expect(function1.id).toBe('say_hi'); + expect(function1.name).toBe('Hi!'); + expect(function1.inputProviders?.[0](ctx, 'unknown-step').id).toBe('name'); + expect(function1.inputProviders?.[0](ctx, 'unknown-step').defaultValue).toBe(undefined); + expect(function1.inputProviders?.[0](ctx, 'unknown-step').required).toBe(true); + expect(function1.inputProviders?.[1](ctx, 'unknown-step').id).toBe('build_number'); + expect(function1.inputProviders?.[1](ctx, 'unknown-step').allowedValueTypeName).toBe( + BuildStepInputValueTypeName.NUMBER + ); + expect(function1.inputProviders?.[1](ctx, 'unknown-step').defaultValue).toBe(undefined); + expect(function1.inputProviders?.[1](ctx, 'unknown-step').required).toBe(true); + expect(function1.inputProviders?.[2](ctx, 'unknown-step').id).toBe('json_input'); + expect(function1.inputProviders?.[2](ctx, 'unknown-step').allowedValueTypeName).toBe( + BuildStepInputValueTypeName.JSON + ); + expect(function1.inputProviders?.[2](ctx, 'unknown-step').defaultValue).toEqual({ + property1: 'value1', + property2: ['value2', { value3: { property3: 'value4' } }], + }); + expect(function1.command).toBe('echo "Hi, ${ inputs.name }!"'); + + // say_hi_wojtek: + // name: Hi, Wojtek! + // command: echo "Hi, Wojtek!" + const function2 = buildFunctions.say_hi_wojtek; + expect(function2.id).toBe('say_hi_wojtek'); + expect(function2.name).toBe('Hi, Wojtek!'); + expect(function2.command).toBe('echo "Hi, Wojtek!"'); + + // random: + // name: Generate random number + // outputs: + // - value + // command: set-output value 6 + const function3 = buildFunctions.random; + expect(function3.id).toBe('random'); + expect(function3.name).toBe('Generate random number'); + expect(function3.outputProviders?.[0](ctx, 'unknown-step').id).toBe('value'); + expect(function3.outputProviders?.[0](ctx, 'unknown-step').required).toBe(true); + expect(function3.command).toBe('set-output value 6'); + + // print: + // inputs: [value] + // command: echo "${ inputs.value }" + const function4 = buildFunctions.print; + expect(function4.id).toBe('print'); + expect(function4.name).toBe(undefined); + expect(function4.inputProviders?.[0](ctx, 'unknown-step').id).toBe('value'); + expect(function4.inputProviders?.[0](ctx, 'unknown-step').required).toBe(true); + expect(function4.command).toBe('echo "${ inputs.value }"'); + + // say_hi_2: + // name: Hi! + // supported_platforms: [darwin, linux] + // inputs: + // - name: greeting + // default_value: Hi + // allowed_values: [Hi, Hello] + // - name: name + // default_value: Brent + // - name: test + // default_value: false + // allowed_values: [false, true] + // type: boolean + // - name: number + // type: number + // command: echo "${ inputs.greeting }, ${ inputs.name }!" + const function5 = buildFunctions.say_hi_2; + expect(function5.id).toBe('say_hi_2'); + expect(function5.name).toBe('Hi!'); + expect(function5.inputProviders?.[0](ctx, 'unknown-step').id).toBe('greeting'); + expect(function5.inputProviders?.[0](ctx, 'unknown-step').required).toBe(true); + expect(function5.inputProviders?.[0](ctx, 'unknown-step').defaultValue).toBe('Hi'); + expect(function5.inputProviders?.[0](ctx, 'unknown-step').allowedValues).toEqual([ + 'Hi', + 'Hello', + ]); + expect(function5.inputProviders?.[2](ctx, 'unknown-step').allowedValueTypeName).toBe( + BuildStepInputValueTypeName.BOOLEAN + ); + expect(function5.inputProviders?.[2](ctx, 'unknown-step').id).toBe('test'); + expect(function5.inputProviders?.[2](ctx, 'unknown-step').required).toBe(true); + expect(function5.inputProviders?.[2](ctx, 'unknown-step').defaultValue).toBe(false); + expect(function5.inputProviders?.[2](ctx, 'unknown-step').allowedValues).toEqual([ + false, + true, + ]); + expect(function5.inputProviders?.[3](ctx, 'unknown-step').allowedValueTypeName).toBe( + BuildStepInputValueTypeName.NUMBER + ); + expect(function5.inputProviders?.[3](ctx, 'unknown-step').id).toBe('number'); + expect(function5.inputProviders?.[3](ctx, 'unknown-step').required).toBe(true); + expect(function5.command).toBe('echo "${ inputs.greeting }, ${ inputs.name }!"'); + expect(function5.supportedRuntimePlatforms).toEqual([ + BuildRuntimePlatform.DARWIN, + BuildRuntimePlatform.LINUX, + ]); + + // my_ts_fn: + // name: My TS function + // inputs: + // - name: name + // - name: num + // type: number + // - name: obj + // type: json + // outputs: + // - name: name + // - name: num + // - name: obj + // path: ./my-custom-ts-function + const function6 = buildFunctions.my_ts_fn; + expect(function6.id).toBe('my_ts_fn'); + expect(function6.name).toBe('My TS function'); + expect(function6.customFunctionModulePath).toMatch(/fixtures\/my-custom-ts-function/); + }); + + it('throws if calling non-existent external functions', async () => { + const ctx = createGlobalContextMock(); + const parser = new BuildConfigParser(ctx, { + configPath: path.join(__dirname, './fixtures/external-functions.yml'), + }); + const error = await getErrorAsync(async () => { + await parser.parseAsync(); + }); + expect(error).toBeInstanceOf(BuildConfigError); + expect(error.message).toBe( + 'Calling non-existent functions: "eas/download_project", "eas/build_project".' + ); + }); + + it('works with external functions', async () => { + const ctx = createGlobalContextMock(); + + const downloadProjectFn: BuildStepFunction = (ctx) => { + ctx.logger.info('Downloading project...'); + }; + + const buildProjectFn: BuildStepFunction = (ctx) => { + ctx.logger.info('Building project...'); + }; + + const parser = new BuildConfigParser(ctx, { + configPath: path.join(__dirname, './fixtures/external-functions.yml'), + externalFunctions: [ + new BuildFunction({ + namespace: 'eas', + id: 'download_project', + fn: downloadProjectFn, + }), + new BuildFunction({ + namespace: 'eas', + id: 'build_project', + fn: buildProjectFn, + }), + ], + }); + + const workflow = await parser.parseAsync(); + expect(workflow.buildSteps.length).toBe(2); + + // - eas/download_project + const step1 = workflow.buildSteps[0]; + expect(step1.id).toMatch(UUID_REGEX); + expect(step1.fn).toBe(downloadProjectFn); + + // - eas/build_project + const step2 = workflow.buildSteps[1]; + expect(step2.id).toMatch(UUID_REGEX); + expect(step2.fn).toBe(buildProjectFn); + }); + + it('works with external function groups', async () => { + const ctx = createGlobalContextMock(); + + const downloadProjectFn: BuildStepFunction = (ctx) => { + ctx.logger.info('Downloading project...'); + }; + + const buildProjectFn: BuildStepFunction = (ctx) => { + ctx.logger.info('Building project...'); + }; + + const parser = new BuildConfigParser(ctx, { + configPath: path.join(__dirname, './fixtures/external-function-groups.yml'), + externalFunctions: [ + new BuildFunction({ + namespace: 'eas', + id: 'download_project', + fn: downloadProjectFn, + }), + new BuildFunction({ + namespace: 'eas', + id: 'build_project', + fn: buildProjectFn, + }), + ], + externalFunctionGroups: [ + new BuildFunctionGroup({ + id: 'build', + namespace: 'eas', + createBuildStepsFromFunctionGroupCall: () => [ + new BuildFunction({ + namespace: 'eas', + id: 'download_project3', + fn: downloadProjectFn, + }).createBuildStepFromFunctionCall(ctx), + new BuildFunction({ + namespace: 'eas', + id: 'build_project4', + fn: buildProjectFn, + }).createBuildStepFromFunctionCall(ctx), + ], + }), + new BuildFunctionGroup({ + id: 'submit', + namespace: 'eas', + createBuildStepsFromFunctionGroupCall: () => [ + new BuildFunction({ + namespace: 'eas', + id: 'download_project5', + fn: downloadProjectFn, + }).createBuildStepFromFunctionCall(ctx), + new BuildFunction({ + namespace: 'eas', + id: 'build_project6', + fn: buildProjectFn, + }).createBuildStepFromFunctionCall(ctx), + new BuildFunction({ + namespace: 'eas', + id: 'test7', + fn: (ctx) => { + ctx.logger.info('Test'); + }, + }).createBuildStepFromFunctionCall(ctx), + ], + }), + ], + }); + + const workflow = await parser.parseAsync(); + expect(workflow.buildSteps.length).toBe(7); + + // - eas/download_project + const step1 = workflow.buildSteps[0]; + expect(step1.id).toMatch(UUID_REGEX); + expect(step1.fn).toBe(downloadProjectFn); + + // - eas/build_project + const step2 = workflow.buildSteps[1]; + expect(step2.id).toMatch(UUID_REGEX); + expect(step2.fn).toBe(buildProjectFn); + + // - eas/download_project3 originating from build group eas/build + const step3 = workflow.buildSteps[2]; + expect(step3.id).toMatch(UUID_REGEX); + expect(step3.fn).toBe(downloadProjectFn); + + // - eas/build_project4 originating from build group eas/build + const step4 = workflow.buildSteps[3]; + expect(step4.id).toMatch(UUID_REGEX); + expect(step4.fn).toBe(buildProjectFn); + + // - eas/download_project5 originating from submit group eas/submit + const step5 = workflow.buildSteps[4]; + expect(step5.id).toMatch(UUID_REGEX); + expect(step5.fn).toBe(downloadProjectFn); + + // - eas/build_project6 originating from submit group eas/submit + const step6 = workflow.buildSteps[5]; + expect(step6.id).toMatch(UUID_REGEX); + expect(step6.fn).toBe(buildProjectFn); + + // - eas/test7 originating from submit group eas/submit + const step7 = workflow.buildSteps[6]; + expect(step7.id).toMatch(UUID_REGEX); + expect(step7.fn).toBeDefined(); + }); + }); +}); diff --git a/packages/steps/src/__tests__/BuildFunction-test.ts b/packages/steps/src/__tests__/BuildFunction-test.ts new file mode 100644 index 0000000000..183eca0d7a --- /dev/null +++ b/packages/steps/src/__tests__/BuildFunction-test.ts @@ -0,0 +1,313 @@ +import { BuildFunction } from '../BuildFunction.js'; +import { BuildStep, BuildStepFunction } from '../BuildStep.js'; +import { + BuildStepInput, + BuildStepInputProvider, + BuildStepInputValueTypeName, +} from '../BuildStepInput.js'; +import { BuildStepOutput, BuildStepOutputProvider } from '../BuildStepOutput.js'; + +import { createGlobalContextMock } from './utils/context.js'; +import { UUID_REGEX } from './utils/uuid.js'; + +describe(BuildFunction, () => { + describe('constructor', () => { + it('throws when command fn and customFunctionModulePath is not set', () => { + expect(() => { + // eslint-disable-next-line no-new + new BuildFunction({ + id: 'test1', + }); + }).toThrowError(/Either command, fn or path must be defined/); + }); + + it('throws when command and fn are both set', () => { + expect(() => { + // eslint-disable-next-line no-new + new BuildFunction({ + id: 'test1', + command: 'echo 123', + fn: () => {}, + }); + }).toThrowError(/Command and fn cannot be both set/); + }); + + it('throws when command and customFunctionModulePath are both set', () => { + expect(() => { + // eslint-disable-next-line no-new + new BuildFunction({ + id: 'test1', + command: 'echo 123', + customFunctionModulePath: 'test', + }); + }).toThrowError(/Command and path cannot be both set/); + }); + + it('throws when fn and customFunctionModulePath are both set', () => { + expect(() => { + // eslint-disable-next-line no-new + new BuildFunction({ + id: 'test1', + fn: () => {}, + customFunctionModulePath: 'test', + }); + }).toThrowError(/Fn and path cannot be both set/); + }); + }); + + describe(BuildFunction.prototype.getFullId, () => { + test('namespace is not defined', () => { + const buildFunction = new BuildFunction({ + id: 'upload_artifacts', + name: 'Test function', + command: 'echo 123', + }); + expect(buildFunction.getFullId()).toBe('upload_artifacts'); + }); + test('namespace is defined', () => { + const buildFunction = new BuildFunction({ + namespace: 'eas', + id: 'upload_artifacts', + name: 'Test function', + command: 'echo 123', + }); + expect(buildFunction.getFullId()).toBe('eas/upload_artifacts'); + }); + }); + + describe(BuildFunction.prototype.createBuildStepFromFunctionCall, () => { + it('returns a BuildStep object', () => { + const ctx = createGlobalContextMock(); + const func = new BuildFunction({ + id: 'test1', + name: 'Test function', + command: 'echo 123', + }); + const step = func.createBuildStepFromFunctionCall(ctx, { + workingDirectory: ctx.defaultWorkingDirectory, + }); + expect(step).toBeInstanceOf(BuildStep); + expect(step.id).toMatch(UUID_REGEX); + expect(step.name).toBe('Test function'); + expect(step.command).toBe('echo 123'); + }); + it('works with build step function', () => { + const ctx = createGlobalContextMock(); + const fn: BuildStepFunction = () => {}; + const buildFunction = new BuildFunction({ + id: 'test1', + name: 'Test function', + fn, + }); + const step = buildFunction.createBuildStepFromFunctionCall(ctx, { + workingDirectory: ctx.defaultWorkingDirectory, + }); + expect(step).toBeInstanceOf(BuildStep); + expect(step.id).toMatch(UUID_REGEX); + expect(step.name).toBe('Test function'); + expect(step.fn).toBe(fn); + }); + it('works with custom JS/TS function', () => { + const ctx = createGlobalContextMock(); + const buildFunction = new BuildFunction({ + id: 'test1', + name: 'Test function', + customFunctionModulePath: './customFunctionTest', + }); + const step = buildFunction.createBuildStepFromFunctionCall(ctx, { + workingDirectory: ctx.defaultWorkingDirectory, + }); + expect(step).toBeInstanceOf(BuildStep); + expect(step.id).toMatch(UUID_REGEX); + expect(step.name).toBe('Test function'); + expect(step.fn).toEqual(expect.any(Function)); + }); + it('can override id and shell from function definition', () => { + const ctx = createGlobalContextMock(); + const func = new BuildFunction({ + id: 'test1', + name: 'Test function', + command: 'echo 123', + shell: '/bin/bash', + }); + const step = func.createBuildStepFromFunctionCall(ctx, { + id: 'test2', + shell: '/bin/zsh', + workingDirectory: ctx.defaultWorkingDirectory, + }); + expect(func.id).toBe('test1'); + expect(func.shell).toBe('/bin/bash'); + expect(step.id).toBe('test2'); + expect(step.shell).toBe('/bin/zsh'); + }); + it('creates function inputs and outputs', () => { + const ctx = createGlobalContextMock(); + const inputProviders: BuildStepInputProvider[] = [ + BuildStepInput.createProvider({ + id: 'input1', + defaultValue: true, + allowedValueTypeName: BuildStepInputValueTypeName.BOOLEAN, + required: true, + }), + BuildStepInput.createProvider({ + id: 'input2', + required: true, + allowedValueTypeName: BuildStepInputValueTypeName.STRING, + }), + BuildStepInput.createProvider({ + id: 'input3', + defaultValue: 1, + allowedValueTypeName: BuildStepInputValueTypeName.NUMBER, + required: true, + }), + BuildStepInput.createProvider({ + id: 'input4', + defaultValue: { a: 1 }, + allowedValueTypeName: BuildStepInputValueTypeName.JSON, + required: true, + }), + BuildStepInput.createProvider({ + id: 'input5', + defaultValue: '${ eas.job.version.buildNumber }', + allowedValueTypeName: BuildStepInputValueTypeName.NUMBER, + required: true, + }), + ]; + const outputProviders: BuildStepOutputProvider[] = [ + BuildStepOutput.createProvider({ id: 'output1', required: true }), + BuildStepOutput.createProvider({ id: 'output2', required: true }), + ]; + const func = new BuildFunction({ + id: 'test1', + name: 'Test function', + command: + 'echo ${ inputs.input1 } ${ inputs.input2 }\nset-output output1 value1\nset-output output2 value2', + inputProviders, + outputProviders, + }); + const step = func.createBuildStepFromFunctionCall(ctx, { + callInputs: { + input1: true, + input2: 'def', + }, + workingDirectory: ctx.defaultWorkingDirectory, + }); + expect(func.inputProviders?.[0]).toBe(inputProviders[0]); + expect(func.inputProviders?.[1]).toBe(inputProviders[1]); + expect(func.inputProviders?.[2]).toBe(inputProviders[2]); + expect(func.inputProviders?.[3]).toBe(inputProviders[3]); + expect(func.outputProviders?.[0]).toBe(outputProviders[0]); + expect(func.outputProviders?.[1]).toBe(outputProviders[1]); + expect(step.inputs?.[0].id).toBe('input1'); + expect(step.inputs?.[1].id).toBe('input2'); + expect(step.inputs?.[2].id).toBe('input3'); + expect(step.outputById.output1).toBeDefined(); + expect(step.outputById.output2).toBeDefined(); + }); + it('passes values to build inputs', () => { + const ctx = createGlobalContextMock(); + const inputProviders: BuildStepInputProvider[] = [ + BuildStepInput.createProvider({ + id: 'input1', + defaultValue: 'xyz1', + required: true, + allowedValueTypeName: BuildStepInputValueTypeName.STRING, + }), + BuildStepInput.createProvider({ + id: 'input2', + defaultValue: 'xyz2', + required: true, + allowedValueTypeName: BuildStepInputValueTypeName.STRING, + }), + BuildStepInput.createProvider({ + id: 'input3', + defaultValue: true, + allowedValueTypeName: BuildStepInputValueTypeName.BOOLEAN, + required: true, + }), + BuildStepInput.createProvider({ + id: 'input4', + defaultValue: { + a: 1, + }, + allowedValueTypeName: BuildStepInputValueTypeName.JSON, + required: true, + }), + ]; + const func = new BuildFunction({ + id: 'test1', + name: 'Test function', + command: 'echo ${ inputs.input1 } ${ inputs.input2 }', + inputProviders, + }); + const step = func.createBuildStepFromFunctionCall(ctx, { + id: 'buildStep1', + callInputs: { + input1: 'abc', + input2: 'def', + input3: false, + input4: { + b: 2, + }, + }, + workingDirectory: ctx.defaultWorkingDirectory, + }); + expect( + step.inputs?.[0].getValue({ + interpolationContext: ctx.getInterpolationContext(), + }) + ).toBe('abc'); + expect( + step.inputs?.[1].getValue({ + interpolationContext: ctx.getInterpolationContext(), + }) + ).toBe('def'); + expect( + step.inputs?.[2].getValue({ + interpolationContext: ctx.getInterpolationContext(), + }) + ).toBe(false); + expect( + step.inputs?.[3].getValue({ + interpolationContext: ctx.getInterpolationContext(), + }) + ).toMatchObject({ + b: 2, + }); + }); + it('passes env to build step', () => { + const ctx = createGlobalContextMock(); + const func = new BuildFunction({ + id: 'test1', + name: 'Test function', + command: 'echo ${ inputs.input1 } ${ inputs.input2 }', + }); + const step = func.createBuildStepFromFunctionCall(ctx, { + id: 'buildStep1', + workingDirectory: ctx.defaultWorkingDirectory, + env: { + ENV1: 'env1', + ENV2: 'env2', + }, + }); + expect(step.stepEnvOverrides).toMatchObject({ + ENV1: 'env1', + ENV2: 'env2', + }); + }); + it('passes ifCondition to build step', () => { + const ctx = createGlobalContextMock(); + const func = new BuildFunction({ + id: 'test1', + name: 'Test function', + command: 'echo test', + }); + const step = func.createBuildStepFromFunctionCall(ctx, { + id: 'buildStep1', + workingDirectory: ctx.defaultWorkingDirectory, + ifCondition: '${ always() }', + }); + expect(step.ifCondition).toBe('${ always() }'); + }); + }); +}); diff --git a/packages/steps/src/__tests__/BuildStep-test.ts b/packages/steps/src/__tests__/BuildStep-test.ts new file mode 100644 index 0000000000..676e2b997b --- /dev/null +++ b/packages/steps/src/__tests__/BuildStep-test.ts @@ -0,0 +1,1261 @@ +import fs from 'fs/promises'; +import path from 'path'; + +import { jest } from '@jest/globals'; +import { instance, mock, verify, when } from 'ts-mockito'; +import { v4 as uuidv4 } from 'uuid'; + +import { BuildStep, BuildStepFunction, BuildStepStatus } from '../BuildStep.js'; +import { BuildStepInput, BuildStepInputValueTypeName } from '../BuildStepInput.js'; +import { BuildStepGlobalContext, BuildStepContext } from '../BuildStepContext.js'; +import { BuildStepOutput } from '../BuildStepOutput.js'; +import { BuildStepRuntimeError } from '../errors.js'; +import { nullthrows } from '../utils/nullthrows.js'; +import { BuildRuntimePlatform } from '../BuildRuntimePlatform.js'; +import { spawnAsync } from '../utils/shell/spawn.js'; +import { BuildStepEnv } from '../BuildStepEnv.js'; +import { BuildFunction } from '../BuildFunction.js'; + +import { createGlobalContextMock } from './utils/context.js'; +import { createMockLogger } from './utils/logger.js'; +import { getError, getErrorAsync } from './utils/error.js'; +import { UUID_REGEX } from './utils/uuid.js'; + +describe(BuildStep, () => { + describe(BuildStep.getNewId, () => { + it('returns a uuid if the user-defined id is undefined', () => { + expect(BuildStep.getNewId()).toMatch(UUID_REGEX); + }); + it('returns the user-defined id if defined', () => { + expect(BuildStep.getNewId('test1')).toBe('test1'); + }); + }); + + describe(BuildStep.getDisplayName, () => { + it('returns the name if defined', () => { + expect(BuildStep.getDisplayName({ id: 'test1', name: 'Step 1' })).toBe('Step 1'); + }); + it("returns the id if it's not a uuid", () => { + expect(BuildStep.getDisplayName({ id: 'test1' })).toBe('test1'); + }); + it('returns the first line of the command if name is undefined and id is a uuid', () => { + expect(BuildStep.getDisplayName({ id: uuidv4(), command: 'echo 123\necho 456' })).toBe( + 'echo 123' + ); + }); + it('returns the first non-comment line of the command', async () => { + expect( + BuildStep.getDisplayName({ id: uuidv4(), command: '# list files\nls -la\necho 123' }) + ).toBe('ls -la'); + }); + it('returns the uuid id if neither name nor command is defined', () => { + const id = uuidv4(); + expect(BuildStep.getDisplayName({ id })).toBe(id); + }); + }); + + describe('constructor', () => { + it('throws when neither command nor fn is set', () => { + const mockCtx = mock(); + when(mockCtx.baseLogger).thenReturn(createMockLogger()); + const ctx = instance(mockCtx); + expect(() => { + const id = 'test1'; + // eslint-disable-next-line no-new + new BuildStep(ctx, { + id, + displayName: BuildStep.getDisplayName({ id }), + workingDirectory: '/tmp', + }); + }).toThrowError(/Either command or fn must be defined/); + }); + + it('throws when neither command nor fn is set', () => { + const mockCtx = mock(); + when(mockCtx.baseLogger).thenReturn(createMockLogger()); + const ctx = instance(mockCtx); + expect(() => { + const id = 'test1'; + const command = 'echo 123'; + const displayName = BuildStep.getDisplayName({ id, command }); + + // eslint-disable-next-line no-new + new BuildStep(ctx, { + id, + displayName, + workingDirectory: '/tmp', + command, + fn: () => {}, + }); + }).toThrowError(/Command and fn cannot be both set/); + }); + + it('calls ctx.registerStep with the new object', () => { + const mockCtx = mock(); + when(mockCtx.baseLogger).thenReturn(createMockLogger()); + when(mockCtx.stepsInternalBuildDirectory).thenReturn('temp-dir'); + const ctx = instance(mockCtx); + + const id = 'test1'; + const command = 'ls -la'; + const displayName = BuildStep.getDisplayName({ id, command }); + + const step = new BuildStep(ctx, { + id, + displayName, + command, + workingDirectory: '/tmp', + }); + // eslint-disable-next-line @typescript-eslint/no-confusing-void-expression + verify(mockCtx.registerStep(step)).called(); + }); + + it('sets the status to NEW', () => { + const ctx = createGlobalContextMock(); + + const id = 'test1'; + const command = 'ls -la'; + const displayName = BuildStep.getDisplayName({ id, command }); + + const step = new BuildStep(ctx, { + id, + displayName, + command, + workingDirectory: '/tmp', + }); + expect(step.status).toBe(BuildStepStatus.NEW); + }); + + it('creates child build context', () => { + const ctx = createGlobalContextMock(); + + const id = 'test1'; + const command = 'ls -la'; + const displayName = BuildStep.getDisplayName({ id, command }); + + const step = new BuildStep(ctx, { + id, + displayName, + command, + }); + expect(step.ctx).toBeInstanceOf(BuildStepContext); + expect(step.ctx).not.toBe(ctx); + }); + + it('creates child build context with correct changed working directory', () => { + const ctx = createGlobalContextMock({ + projectTargetDirectory: '/a/b', + relativeWorkingDirectory: 'c', + }); + ctx.markAsCheckedOut(ctx.baseLogger); + + const id = 'test1'; + const command = 'ls -la'; + const displayName = BuildStep.getDisplayName({ id, command }); + + const step = new BuildStep(ctx, { + id, + displayName, + command, + workingDirectory: 'd/e/f', + }); + expect(step.ctx.workingDirectory).toBe('/a/b/c/d/e/f'); + }); + + it('creates child build context with unchanged working directory', () => { + const ctx = createGlobalContextMock({ + projectTargetDirectory: '/a/b', + relativeWorkingDirectory: 'c', + }); + ctx.markAsCheckedOut(ctx.baseLogger); + + const id = 'test1'; + const command = 'ls -la'; + const displayName = BuildStep.getDisplayName({ id, command }); + + const step = new BuildStep(ctx, { + id, + command, + displayName, + }); + expect(step.ctx.workingDirectory).toBe('/a/b/c'); + }); + + it('creates child build context with child logger', () => { + const ctx = createGlobalContextMock(); + + const id = 'test1'; + const name = 'Test step'; + const command = 'ls -la'; + const displayName = BuildStep.getDisplayName({ id, name, command }); + + const step = new BuildStep(ctx, { + id, + name, + displayName, + command, + }); + expect(ctx.baseLogger.child).toHaveBeenCalledWith( + expect.objectContaining({ + buildStepInternalId: expect.stringMatching(UUID_REGEX), + buildStepId: 'test1', + buildStepDisplayName: 'Test step', + }) + ); + expect(step.ctx.logger).not.toBe(ctx.baseLogger); + }); + }); + + describe(BuildStep.prototype.executeAsync, () => { + let baseStepCtx: BuildStepGlobalContext; + + beforeEach(async () => { + baseStepCtx = createGlobalContextMock({ + runtimePlatform: BuildRuntimePlatform.LINUX, + }); + await fs.mkdir(baseStepCtx.defaultWorkingDirectory, { recursive: true }); + await fs.mkdir(baseStepCtx.stepsInternalBuildDirectory, { recursive: true }); + }); + afterEach(async () => { + await fs.rm(baseStepCtx.stepsInternalBuildDirectory, { recursive: true }); + }); + + it('sets status to FAIL when step fails', async () => { + const id = 'test1'; + const command = 'false'; + const displayName = BuildStep.getDisplayName({ id, command }); + + const step = new BuildStep(baseStepCtx, { + id, + command, + displayName, + }); + await expect(step.executeAsync()).rejects.toThrow(); + expect(step.status).toBe(BuildStepStatus.FAIL); + }); + + it('sets status to SUCCESS when step succeeds', async () => { + const id = 'test1'; + const command = 'true'; + const displayName = BuildStep.getDisplayName({ id, command }); + + const step = new BuildStep(baseStepCtx, { + id, + displayName, + command, + }); + await step.executeAsync(); + expect(step.status).toBe(BuildStepStatus.SUCCESS); + }); + + describe('command', () => { + it('logs an error if the command is to be executed in non-existing working directory', async () => { + const id = 'test1'; + const command = 'ls -la'; + const displayName = BuildStep.getDisplayName({ id, command }); + + const step = new BuildStep(baseStepCtx, { + id, + command, + displayName, + workingDirectory: 'non-existing-directory', + }); + + let err; + try { + await step.executeAsync(); + } catch (error) { + err = error; + } + + expect(err).toBeDefined(); + expect(step.ctx.logger.error).toHaveBeenCalledWith( + expect.anything(), + expect.stringContaining( + `Working directory "${path.join(baseStepCtx.defaultWorkingDirectory, 'non-existing-directory')}" does not exist` + ) + ); + }); + + it('does not log an error if the command is to be executed in a directory that exists', async () => { + const id = 'test1'; + const command = 'ls -la'; + const displayName = BuildStep.getDisplayName({ id, command }); + + await fs.mkdir(path.join(baseStepCtx.defaultWorkingDirectory, 'existing-directory'), { + recursive: true, + }); + + const step = new BuildStep(baseStepCtx, { + id, + command, + displayName, + workingDirectory: 'existing-directory', + }); + + let err; + try { + await step.executeAsync(); + } catch (error) { + err = error; + } + + expect(err).toBeUndefined(); + expect(step.ctx.logger.error).not.toHaveBeenCalled(); + }); + + it('executes the command passed to the step', async () => { + const logger = createMockLogger(); + const lines: string[] = []; + jest + .mocked(logger.info as any) + .mockImplementation((obj: object | string, line?: string) => { + if (typeof obj === 'string') { + lines.push(obj); + } else if (line) { + lines.push(line); + } + }); + jest.mocked(logger.child).mockReturnValue(logger); + (baseStepCtx as any).baseLogger = logger; + + await Promise.all([ + fs.writeFile( + path.join(baseStepCtx.defaultWorkingDirectory, 'expo-abc123'), + 'lorem ipsum' + ), + fs.writeFile( + path.join(baseStepCtx.defaultWorkingDirectory, 'expo-def456'), + 'lorem ipsum' + ), + fs.writeFile( + path.join(baseStepCtx.defaultWorkingDirectory, 'expo-ghi789'), + 'lorem ipsum' + ), + ]); + + const id = 'test1'; + const command = 'ls -la'; + const displayName = BuildStep.getDisplayName({ id, command }); + + const step = new BuildStep(baseStepCtx, { + id, + command, + displayName, + }); + await step.executeAsync(); + + expect(lines.find((line) => line.match('expo-abc123'))).toBeTruthy(); + expect(lines.find((line) => line.match('expo-def456'))).toBeTruthy(); + expect(lines.find((line) => line.match('expo-ghi789'))).toBeTruthy(); + }); + + it('interpolates the inputs in command template', async () => { + const id = 'test1'; + const command = "set-output foo2 '${inputs.foo1} ${inputs.foo2} ${inputs.foo3}'"; + const displayName = BuildStep.getDisplayName({ id, command }); + + const step = new BuildStep(baseStepCtx, { + id, + displayName, + inputs: [ + new BuildStepInput(baseStepCtx, { + id: 'foo1', + stepDisplayName: displayName, + defaultValue: 'bar', + required: true, + allowedValueTypeName: BuildStepInputValueTypeName.STRING, + }), + new BuildStepInput(baseStepCtx, { + id: 'foo2', + stepDisplayName: displayName, + defaultValue: '${ eas.runtimePlatform }', + allowedValueTypeName: BuildStepInputValueTypeName.STRING, + required: true, + }), + new BuildStepInput(baseStepCtx, { + id: 'foo3', + stepDisplayName: displayName, + defaultValue: { + foo: 'bar', + baz: [1, 'aaa'], + }, + allowedValueTypeName: BuildStepInputValueTypeName.JSON, + required: true, + }), + ], + outputs: [ + new BuildStepOutput(baseStepCtx, { + id: 'foo2', + stepDisplayName: displayName, + required: true, + }), + ], + command, + }); + await step.executeAsync(); + expect(step.getOutputValueByName('foo2')).toBe('bar linux {"foo":"bar","baz":[1,"aaa"]}'); + }); + + it('interpolates the outputs in command template', async () => { + const stepWithOutput = new BuildFunction({ + id: 'func', + fn: (_ctx, { outputs }) => { + outputs.foo.set('bar'); + }, + outputProviders: [ + BuildStepOutput.createProvider({ + id: 'foo', + required: true, + }), + ], + }).createBuildStepFromFunctionCall(baseStepCtx, { + id: 'step1', + }); + await stepWithOutput.executeAsync(); + expect(stepWithOutput.getOutputValueByName('foo')).toBe('bar'); + + const step = new BuildStep(baseStepCtx, { + id: 'step2', + command: "set-output foo2 '${ steps.step1.foo }'", + displayName: 'Step 2', + outputs: [ + new BuildStepOutput(baseStepCtx, { + id: 'foo2', + stepDisplayName: 'Step 2', + required: true, + }), + ], + }); + await step.executeAsync(); + expect(step.getOutputValueByName('foo2')).toBe('bar'); + }); + + it('collects the outputs after calling the script', async () => { + const id = 'test1'; + const command = 'set-output abc 123'; + const displayName = BuildStep.getDisplayName({ id, command }); + + const step = new BuildStep(baseStepCtx, { + id, + displayName, + outputs: [ + new BuildStepOutput(baseStepCtx, { + id: 'abc', + stepDisplayName: displayName, + required: true, + }), + ], + command, + }); + await step.executeAsync(); + const abc = nullthrows(step.outputById.abc); + expect(abc?.value).toBe('123'); + }); + + it('collects the envs after calling the fn', async () => { + const id = 'test1'; + const fn = jest.fn(async (ctx: BuildStepContext, { env }: { env: BuildStepEnv }) => { + await spawnAsync('set-env', ['ABC', '123'], { + cwd: ctx.workingDirectory, + env, + }); + }); + const displayName = BuildStep.getDisplayName({ id }); + + const step = new BuildStep(baseStepCtx, { + id, + displayName, + fn, + }); + await step.executeAsync(); + expect(baseStepCtx.env).toMatchObject({ ABC: '123' }); + }); + + it('collects the outputs after calling the fn', async () => { + const id = 'test1'; + const fn = jest.fn(async (ctx: BuildStepContext, { env }: { env: BuildStepEnv }) => { + await spawnAsync('set-output', ['abc', '123'], { + cwd: ctx.workingDirectory, + env, + }); + }); + const displayName = BuildStep.getDisplayName({ id }); + + const step = new BuildStep(baseStepCtx, { + id, + displayName, + outputs: [ + new BuildStepOutput(baseStepCtx, { + id: 'abc', + stepDisplayName: displayName, + required: true, + }), + ], + fn, + }); + await step.executeAsync(); + const abc = nullthrows(step.outputById.abc); + expect(abc?.value).toBe('123'); + }); + }); + + describe('timeout', () => { + it('succeeds when step completes within timeout', async () => { + const id = 'test1'; + const command = 'sleep 0.1'; + const displayName = BuildStep.getDisplayName({ id, command }); + + const step = new BuildStep(baseStepCtx, { + id, + command, + displayName, + timeoutMs: 1000, // 1 second timeout + }); + await step.executeAsync(); + expect(step.status).toBe(BuildStepStatus.SUCCESS); + }); + + it('fails when command exceeds timeout', async () => { + const id = 'test1'; + const command = 'sleep 2'; + const displayName = BuildStep.getDisplayName({ id, command }); + + const step = new BuildStep(baseStepCtx, { + id, + command, + displayName, + timeoutMs: 100, // 100ms timeout + }); + + const error = await getErrorAsync(() => step.executeAsync()); + expect(error).toBeInstanceOf(BuildStepRuntimeError); + expect(error.message).toMatch(/timed out after 100ms/); + expect(step.status).toBe(BuildStepStatus.FAIL); + }); + + it('fails when function exceeds timeout', async () => { + const id = 'test1'; + const fn = jest.fn( + async () => + await new Promise((resolve) => { + setTimeout(resolve, 2000); // 2 second delay + }) + ); + const displayName = BuildStep.getDisplayName({ id }); + + const step = new BuildStep(baseStepCtx, { + id, + displayName, + fn, + timeoutMs: 100, // 100ms timeout + }); + + const error = await getErrorAsync(() => step.executeAsync()); + expect(error).toBeInstanceOf(BuildStepRuntimeError); + expect(error.message).toMatch(/timed out after 100ms/); + expect(step.status).toBe(BuildStepStatus.FAIL); + }); + + it('works without timeout when timeoutMs is undefined', async () => { + const id = 'test1'; + const command = 'sleep 0.1'; + const displayName = BuildStep.getDisplayName({ id, command }); + + const step = new BuildStep(baseStepCtx, { + id, + command, + displayName, + // No timeoutMs specified + }); + await step.executeAsync(); + expect(step.status).toBe(BuildStepStatus.SUCCESS); + }); + }); + + describe('outputs', () => { + it('works with strings with whitespaces passed as a value for an output parameter', async () => { + const id = 'test1'; + const command = 'set-output abc "d o m i n i k"'; + const displayName = BuildStep.getDisplayName({ id, command }); + + const step = new BuildStep(baseStepCtx, { + id, + displayName, + outputs: [ + new BuildStepOutput(baseStepCtx, { + id: 'abc', + stepDisplayName: displayName, + required: true, + }), + ], + command, + }); + await step.executeAsync(); + const abc = nullthrows(step.outputById.abc); + expect(abc?.value).toBe('d o m i n i k'); + }); + + it('throws an error if some required outputs have not been set with set-output in script', async () => { + const id = 'test1'; + const command = 'echo 123'; + const displayName = BuildStep.getDisplayName({ id, command }); + + const step = new BuildStep(baseStepCtx, { + id, + displayName, + outputs: [ + new BuildStepOutput(baseStepCtx, { + id: 'abc', + stepDisplayName: displayName, + required: true, + }), + ], + command, + }); + const error = await getErrorAsync(() => step.executeAsync()); + expect(error).toBeInstanceOf(BuildStepRuntimeError); + expect(error.message).toMatch(/Some required outputs have not been set: "abc"/); + }); + }); + + describe('fn', () => { + it('executes the function passed to the step', async () => { + const fnMock = jest.fn(); + + const globalEnv = { TEST1: 'abc' }; + const stepEnv = { TEST2: 'def' }; + + baseStepCtx.updateEnv(globalEnv); + + const id = 'test1'; + const displayName = BuildStep.getDisplayName({ id }); + + const step = new BuildStep(baseStepCtx, { + id, + displayName, + fn: fnMock, + env: stepEnv, + }); + + await step.executeAsync(); + + expect(fnMock).toHaveBeenCalledWith( + step.ctx, + expect.objectContaining({ + inputs: expect.any(Object), + outputs: expect.any(Object), + env: expect.objectContaining({ + ...globalEnv, + ...stepEnv, + }), + }) + ); + }); + + it('when executing the function passed to step, step envs override global envs', async () => { + const fnMock = jest.fn(); + + const globalEnv = { TEST1: 'abc' }; + const stepEnv = { TEST1: 'def' }; + + baseStepCtx.updateEnv(globalEnv); + + const id = 'test1'; + const displayName = BuildStep.getDisplayName({ id }); + + const step = new BuildStep(baseStepCtx, { + id, + displayName, + fn: fnMock, + env: stepEnv, + }); + + await step.executeAsync(); + + expect(fnMock).toHaveBeenCalledWith( + step.ctx, + expect.objectContaining({ + inputs: expect.any(Object), + outputs: expect.any(Object), + env: expect.objectContaining({ + TEST1: 'def', + }), + }) + ); + }); + + it('passes input and outputs to the function', async () => { + const env = { TEST_VAR_1: 'abc' }; + baseStepCtx.updateEnv(env); + + const id = 'test1'; + const displayName = BuildStep.getDisplayName({ id }); + + const inputs: BuildStepInput[] = [ + new BuildStepInput(baseStepCtx, { + id: 'foo1', + stepDisplayName: displayName, + defaultValue: 'bar1', + required: true, + allowedValueTypeName: BuildStepInputValueTypeName.STRING, + }), + new BuildStepInput(baseStepCtx, { + id: 'foo2', + stepDisplayName: displayName, + defaultValue: 'bar2', + allowedValueTypeName: BuildStepInputValueTypeName.STRING, + required: true, + }), + new BuildStepInput(baseStepCtx, { + id: 'foo3', + stepDisplayName: displayName, + defaultValue: true, + allowedValueTypeName: BuildStepInputValueTypeName.BOOLEAN, + required: true, + }), + new BuildStepInput(baseStepCtx, { + id: 'foo4', + stepDisplayName: displayName, + defaultValue: 27, + allowedValueTypeName: BuildStepInputValueTypeName.NUMBER, + required: true, + }), + new BuildStepInput(baseStepCtx, { + id: 'foo5', + stepDisplayName: displayName, + defaultValue: { + foo: 'bar', + }, + allowedValueTypeName: BuildStepInputValueTypeName.JSON, + required: true, + }), + ]; + const outputs: BuildStepOutput[] = [ + new BuildStepOutput(baseStepCtx, { + id: 'abc', + stepDisplayName: displayName, + required: true, + }), + ]; + + const fn: BuildStepFunction = (_ctx, { inputs, outputs }) => { + outputs.abc.set( + `${inputs.foo1?.value} ${inputs.foo2?.value} ${inputs.foo3?.value} ${inputs.foo4?.value}` + ); + }; + + const step = new BuildStep(baseStepCtx, { + id, + displayName, + inputs, + outputs, + fn, + }); + + await step.executeAsync(); + + expect(step.getOutputValueByName('abc')).toBe('bar1 bar2 true 27'); + }); + }); + }); + + describe(BuildStep.prototype.getOutputValueByName, () => { + let baseStepCtx: BuildStepGlobalContext; + + beforeEach(async () => { + baseStepCtx = createGlobalContextMock(); + await fs.mkdir(baseStepCtx.defaultWorkingDirectory, { recursive: true }); + await fs.mkdir(baseStepCtx.stepsInternalBuildDirectory, { recursive: true }); + }); + afterEach(async () => { + await fs.rm(baseStepCtx.stepsInternalBuildDirectory, { recursive: true }); + }); + + it('throws an error when the step has not been executed yet', async () => { + const id = 'test1'; + const command = 'set-output abc 123'; + const displayName = BuildStep.getDisplayName({ id, command }); + + const step = new BuildStep(baseStepCtx, { + id, + displayName, + outputs: [ + new BuildStepOutput(baseStepCtx, { + id: 'abc', + stepDisplayName: displayName, + required: true, + }), + ], + command, + }); + const error = getError(() => { + step.getOutputValueByName('abc'); + }); + expect(error).toBeInstanceOf(BuildStepRuntimeError); + expect(error.message).toMatch(/The step has not been executed yet/); + }); + + it('throws an error when trying to access a non-existent output', async () => { + const id = 'test1'; + const command = 'set-output abc 123'; + const displayName = BuildStep.getDisplayName({ id, command }); + + const step = new BuildStep(baseStepCtx, { + id, + displayName, + outputs: [ + new BuildStepOutput(baseStepCtx, { + id: 'abc', + stepDisplayName: displayName, + required: true, + }), + ], + command, + }); + await step.executeAsync(); + const error = getError(() => { + step.getOutputValueByName('def'); + }); + expect(error).toBeInstanceOf(BuildStepRuntimeError); + expect(error.message).toMatch(/Step "test1" does not have output "def"/); + }); + + it('returns the output value', async () => { + const id = 'test1'; + const command = 'set-output abc 123'; + const displayName = BuildStep.getDisplayName({ id, command }); + + const step = new BuildStep(baseStepCtx, { + id, + displayName, + outputs: [ + new BuildStepOutput(baseStepCtx, { + id: 'abc', + stepDisplayName: displayName, + required: true, + }), + ], + command, + }); + await step.executeAsync(); + expect(step.getOutputValueByName('abc')).toBe('123'); + }); + + it('propagates environment variables to the script', async () => { + baseStepCtx.updateEnv({ TEST_ABC: 'lorem ipsum' }); + const logger = createMockLogger(); + const lines: string[] = []; + jest.mocked(logger.info as any).mockImplementation((obj: object | string, line?: string) => { + if (typeof obj === 'string') { + lines.push(obj); + } else if (line) { + lines.push(line); + } + }); + jest.mocked(logger.child).mockReturnValue(logger); + + const id = 'test1'; + const command = 'echo "$TEST_ABC $TEST_DEF"'; + const displayName = BuildStep.getDisplayName({ id, command }); + + (baseStepCtx as any).baseLogger = logger; + + const step = new BuildStep(baseStepCtx, { + id, + displayName, + command, + env: { + TEST_DEF: 'dolor sit amet', + }, + }); + await step.executeAsync(); + expect(lines.find((line) => line.match('lorem ipsum dolor sit amet'))).toBeTruthy(); + }); + + it('when running a script step envs override gloabl envs', async () => { + baseStepCtx.updateEnv({ TEST_ABC: 'lorem ipsum' }); + const logger = createMockLogger(); + const lines: string[] = []; + jest.mocked(logger.info as any).mockImplementation((obj: object | string, line?: string) => { + if (typeof obj === 'string') { + lines.push(obj); + } else if (line) { + lines.push(line); + } + }); + jest.mocked(logger.child).mockReturnValue(logger); + + const id = 'test1'; + const command = 'echo $TEST_ABC'; + const displayName = BuildStep.getDisplayName({ id, command }); + + (baseStepCtx as any).baseLogger = logger; + + const step = new BuildStep(baseStepCtx, { + id, + displayName, + command, + env: { + TEST_ABC: 'dolor sit amet', + }, + }); + await step.executeAsync(); + expect(lines.find((line) => line.match('dolor sit amet'))).toBeTruthy(); + expect(lines.find((line) => line.match('lorem ipsum'))).toBeUndefined(); + }); + + it('executes the command with internal environment variables', async () => { + const logger = createMockLogger(); + const lines: string[] = []; + jest.mocked(logger.info as any).mockImplementation((obj: object | string, line?: string) => { + if (typeof obj === 'string') { + lines.push(obj); + } else if (line) { + lines.push(line); + } + }); + jest.mocked(logger.child).mockReturnValue(logger); + + const id = 'test1'; + const command = + 'echo $__EXPO_STEPS_BUILD_ID\necho $__EXPO_STEPS_OUTPUTS_DIR\necho $__EXPO_STEPS_ENVS_DIR\necho $__EXPO_STEPS_WORKING_DIRECTORY'; + const displayName = BuildStep.getDisplayName({ id, command }); + + (baseStepCtx as any).baseLogger = logger; + const step = new BuildStep(baseStepCtx, { + id, + displayName, + command, + }); + await step.executeAsync(); + expect( + lines.find((line) => + line.startsWith(path.join(baseStepCtx.stepsInternalBuildDirectory, 'steps/test1/envs')) + ) + ).toBeTruthy(); + expect( + lines.find((line) => + line.startsWith(path.join(baseStepCtx.stepsInternalBuildDirectory, 'steps/test1/outputs')) + ) + ).toBeTruthy(); + expect(lines.find((line) => line.match(baseStepCtx.defaultWorkingDirectory))).toBeTruthy(); + }); + it('can update global env object with set-env', async () => { + const id = 'test1'; + const command = 'set-env EXAMPLE value'; + const displayName = BuildStep.getDisplayName({ id, command }); + + const step = new BuildStep(baseStepCtx, { + id, + displayName, + command, + }); + await step.executeAsync(); + expect(baseStepCtx.env.EXAMPLE).toBe('value'); + }); + it('can override existing envs in global env object with set-env', async () => { + const id = 'test1'; + const command = 'set-env EXAMPLE value'; + const displayName = BuildStep.getDisplayName({ id, command }); + + baseStepCtx.updateEnv({ + EXAMPLE: 'test1', + EXAMPLE_2: 'test2', + }); + const step = new BuildStep(baseStepCtx, { + id, + displayName, + command, + }); + await step.executeAsync(); + expect(baseStepCtx.env.EXAMPLE).toBe('value'); + expect(baseStepCtx.env.EXAMPLE_2).toBe('test2'); + }); + }); +}); + +describe(BuildStep.prototype.canBeRunOnRuntimePlatform, () => { + let baseStepCtx: BuildStepGlobalContext; + + beforeEach(async () => { + baseStepCtx = createGlobalContextMock({ runtimePlatform: BuildRuntimePlatform.LINUX }); + await fs.mkdir(baseStepCtx.defaultWorkingDirectory, { recursive: true }); + await fs.mkdir(baseStepCtx.stepsInternalBuildDirectory, { recursive: true }); + }); + afterEach(async () => { + await fs.rm(baseStepCtx.stepsInternalBuildDirectory, { recursive: true }); + }); + + it('returns true when the step does not have a platform filter', async () => { + const id = 'test1'; + const command = 'set-output abc 123'; + const displayName = BuildStep.getDisplayName({ id, command }); + + const step = new BuildStep(baseStepCtx, { + id, + displayName, + command, + }); + expect(step.canBeRunOnRuntimePlatform()).toBe(true); + }); + + it('returns true when the step has a platform filter and the platform matches', async () => { + const id = 'test1'; + const command = 'set-output abc 123'; + const displayName = BuildStep.getDisplayName({ id, command }); + + const step = new BuildStep(baseStepCtx, { + id, + displayName, + supportedRuntimePlatforms: [BuildRuntimePlatform.DARWIN, BuildRuntimePlatform.LINUX], + command, + }); + expect(step.canBeRunOnRuntimePlatform()).toBe(true); + }); + + it('returns false when the step has a platform filter and the platform does not match', async () => { + const id = 'test1'; + const command = 'set-output abc 123'; + const displayName = BuildStep.getDisplayName({ id, command }); + + const step = new BuildStep(baseStepCtx, { + id, + displayName, + supportedRuntimePlatforms: [BuildRuntimePlatform.DARWIN], + command, + }); + expect(step.canBeRunOnRuntimePlatform()).toBe(false); + }); +}); + +describe(BuildStep.prototype.serialize, () => { + let baseStepCtx: BuildStepGlobalContext; + + beforeEach(async () => { + baseStepCtx = createGlobalContextMock({ runtimePlatform: BuildRuntimePlatform.LINUX }); + await fs.mkdir(baseStepCtx.defaultWorkingDirectory, { recursive: true }); + await fs.mkdir(baseStepCtx.stepsInternalBuildDirectory, { recursive: true }); + }); + afterEach(async () => { + await fs.rm(baseStepCtx.stepsInternalBuildDirectory, { recursive: true }); + }); + + it('serializes correctly', async () => { + const id = 'test1'; + const command = 'set-output abc 123'; + const displayName = BuildStep.getDisplayName({ id, command }); + + const outputs = [ + new BuildStepOutput(baseStepCtx, { + id: 'abc', + stepDisplayName: displayName, + required: true, + }), + ]; + + const step = new BuildStep(baseStepCtx, { + id, + displayName, + command, + outputs, + }); + expect(step.serialize()).toMatchObject({ + id, + displayName, + executed: false, + outputById: { + abc: outputs[0].serialize(), + }, + }); + }); +}); + +describe(BuildStep.deserialize, () => { + let baseStepCtx: BuildStepGlobalContext; + + beforeEach(async () => { + baseStepCtx = createGlobalContextMock({ runtimePlatform: BuildRuntimePlatform.LINUX }); + await fs.mkdir(baseStepCtx.defaultWorkingDirectory, { recursive: true }); + await fs.mkdir(baseStepCtx.stepsInternalBuildDirectory, { recursive: true }); + }); + afterEach(async () => { + await fs.rm(baseStepCtx.stepsInternalBuildDirectory, { recursive: true }); + }); + + it('deserializes correctly', async () => { + const outputs = [ + new BuildStepOutput(baseStepCtx, { + id: 'abc', + stepDisplayName: 'Test 1', + required: true, + }), + ]; + outputs[0].set('123'); + const step = BuildStep.deserialize({ + id: 'test1', + displayName: 'Test 1', + executed: true, + outputById: { + abc: outputs[0].serialize(), + }, + }); + expect(step.id).toBe('test1'); + expect(step.displayName).toBe('Test 1'); + expect(step.getOutputValueByName('abc')).toBe('123'); + }); +}); + +describe(BuildStep.prototype.shouldExecuteStep, () => { + it('returns true when if condition is always and previous steps failed', () => { + const ctx = createGlobalContextMock(); + ctx.markAsFailed(); + const step = new BuildStep(ctx, { + id: 'test1', + displayName: 'Test 1', + command: 'echo 123', + ifCondition: '${ always() }', + }); + expect(step.shouldExecuteStep()).toBe(true); + }); + + it('returns true when if condition is always and previous steps have not failed', () => { + const ctx = createGlobalContextMock(); + const step = new BuildStep(ctx, { + id: 'test1', + displayName: 'Test 1', + command: 'echo 123', + ifCondition: '${ always() }', + }); + expect(step.shouldExecuteStep()).toBe(true); + }); + + it('returns false when if condition is success and previous steps failed', () => { + const ctx = createGlobalContextMock(); + ctx.markAsFailed(); + const step = new BuildStep(ctx, { + id: 'test1', + displayName: 'Test 1', + command: 'echo 123', + ifCondition: '${ success() }', + }); + expect(step.shouldExecuteStep()).toBe(false); + }); + + it('returns true when a dynamic expression matches', () => { + const ctx = createGlobalContextMock(); + ctx.updateEnv({ + NODE_ENV: 'production', + }); + const step = new BuildStep(ctx, { + id: 'test1', + displayName: 'Test 1', + command: 'echo 123', + env: { + LOCAL_ENV: 'true', + }, + ifCondition: '${ env.NODE_ENV === "production" && env.LOCAL_ENV === "true" }', + }); + expect(step.shouldExecuteStep()).toBe(true); + }); + + it('can use the general interpolation context', () => { + const ctx = createGlobalContextMock(); + ctx.updateEnv({ + CONFIG_JSON: '{"foo": "bar"}', + }); + const step = new BuildStep(ctx, { + id: 'test1', + displayName: 'Test 1', + command: 'echo 123', + ifCondition: 'fromJSON(env.CONFIG_JSON).foo == "bar"', + }); + expect(step.shouldExecuteStep()).toBe(true); + }); + + it('returns true when a simplified dynamic expression matches', () => { + const ctx = createGlobalContextMock(); + const step = new BuildStep(ctx, { + id: 'test1', + displayName: 'Test 1', + command: 'echo 123', + env: { + NODE_ENV: 'production', + }, + ifCondition: "env.NODE_ENV === 'production'", + }); + expect(step.shouldExecuteStep()).toBe(true); + }); + + it('returns true when an input matches', () => { + const ctx = createGlobalContextMock(); + const step = new BuildStep(ctx, { + id: 'test1', + displayName: 'Test 1', + command: 'echo 123', + env: { + NODE_ENV: 'production', + }, + inputs: [ + new BuildStepInput(ctx, { + id: 'foo1', + stepDisplayName: 'Test 1', + defaultValue: 'bar', + required: true, + allowedValueTypeName: BuildStepInputValueTypeName.STRING, + }), + ], + ifCondition: 'inputs.foo1 === "bar"', + }); + expect(step.shouldExecuteStep()).toBe(true); + }); + + it('returns true when an eas value matches', () => { + const ctx = createGlobalContextMock({ runtimePlatform: BuildRuntimePlatform.LINUX }); + const step = new BuildStep(ctx, { + id: 'test1', + displayName: 'Test 1', + command: 'echo 123', + ifCondition: 'eas.runtimePlatform === "linux"', + }); + expect(step.shouldExecuteStep()).toBe(true); + }); + + it('returns true when if condition is success and previous steps have not failed', () => { + const ctx = createGlobalContextMock(); + const step = new BuildStep(ctx, { + id: 'test1', + displayName: 'Test 1', + command: 'echo 123', + ifCondition: '${ success() }', + }); + expect(step.shouldExecuteStep()).toBe(true); + }); + + it('returns true when if condition is failure and previous steps failed', () => { + const ctx = createGlobalContextMock(); + ctx.markAsFailed(); + for (const ifCondition of ['${ failure() }', '${{ failure() }}']) { + const step = new BuildStep(ctx, { + id: 'test1', + displayName: 'Test 1', + command: 'echo 123', + ifCondition, + }); + expect(step.shouldExecuteStep()).toBe(true); + } + }); + + it('returns false when if condition is failure and previous steps have not failed', () => { + const ctx = createGlobalContextMock(); + for (const ifCondition of ['${ failure() }', '${{ failure() }}']) { + const step = new BuildStep(ctx, { + id: 'test1', + displayName: 'Test 1', + command: 'echo 123', + ifCondition, + }); + expect(step.shouldExecuteStep()).toBe(false); + } + }); +}); diff --git a/packages/steps/src/__tests__/BuildStepContext-test.ts b/packages/steps/src/__tests__/BuildStepContext-test.ts new file mode 100644 index 0000000000..3c7603244b --- /dev/null +++ b/packages/steps/src/__tests__/BuildStepContext-test.ts @@ -0,0 +1,353 @@ +import path from 'path'; +import os from 'os'; +import fs from 'fs'; +import crypto from 'crypto'; + +import { JobInterpolationContext } from '@expo/eas-build-job'; +import { instance, mock, when } from 'ts-mockito'; + +import { BuildStep } from '../BuildStep.js'; +import { BuildStepGlobalContext, BuildStepContext } from '../BuildStepContext.js'; +import { BuildStepRuntimeError } from '../errors.js'; +import { BuildRuntimePlatform } from '../BuildRuntimePlatform.js'; + +import { createGlobalContextMock, MockContextProvider } from './utils/context.js'; +import { getError } from './utils/error.js'; +import { createMockLogger } from './utils/logger.js'; + +describe(BuildStepGlobalContext, () => { + describe('stepsInternalBuildDirectory', () => { + it('is in os.tmpdir()', () => { + const ctx = new BuildStepGlobalContext( + new MockContextProvider( + createMockLogger(), + BuildRuntimePlatform.LINUX, + '/non/existent/path', + '/another/non/existent/path', + '/working/dir/path', + '/non/existent/path', + {} as unknown as JobInterpolationContext + ), + false + ); + expect(ctx.stepsInternalBuildDirectory.startsWith(os.tmpdir())).toBe(true); + }); + }); + describe('workingDirectory', () => { + it('if not checked out uses project target dir as default working dir', () => { + const workingDirectory = '/path/to/working/dir'; + const projectTargetDirectory = '/another/non/existent/path'; + const ctx = new BuildStepGlobalContext( + new MockContextProvider( + createMockLogger(), + BuildRuntimePlatform.LINUX, + '/non/existent/path', + projectTargetDirectory, + workingDirectory, + '/non/existent/path', + {} as unknown as JobInterpolationContext + ), + false + ); + expect(ctx.defaultWorkingDirectory).toBe(projectTargetDirectory); + }); + + it('if checked out uses default working dir as default working dir', () => { + const workingDirectory = '/path/to/working/dir'; + const projectTargetDirectory = '/another/non/existent/path'; + const ctx = new BuildStepGlobalContext( + new MockContextProvider( + createMockLogger(), + BuildRuntimePlatform.LINUX, + '/non/existent/path', + projectTargetDirectory, + workingDirectory, + '/non/existent/path', + {} as unknown as JobInterpolationContext + ), + false + ); + ctx.markAsCheckedOut(ctx.baseLogger); + expect(ctx.defaultWorkingDirectory).toBe(workingDirectory); + }); + }); + describe(BuildStepGlobalContext.prototype.registerStep, () => { + it('exists', () => { + const ctx = createGlobalContextMock(); + expect(typeof ctx.registerStep).toBe('function'); + }); + }); + describe(BuildStepGlobalContext.prototype.serialize, () => { + it('serializes global context', () => { + const ctx = createGlobalContextMock({ + skipCleanup: true, + runtimePlatform: BuildRuntimePlatform.DARWIN, + projectSourceDirectory: '/a/b/c', + projectTargetDirectory: '/d/e/f', + relativeWorkingDirectory: 'i', + staticContextContent: { a: 1 } as unknown as JobInterpolationContext, + }); + expect(ctx.serialize()).toEqual( + expect.objectContaining({ + stepsInternalBuildDirectory: ctx.stepsInternalBuildDirectory, + stepById: {}, + provider: { + projectSourceDirectory: '/a/b/c', + projectTargetDirectory: '/d/e/f', + defaultWorkingDirectory: '/d/e/f/i', + buildLogsDirectory: '/non/existent/dir', + runtimePlatform: BuildRuntimePlatform.DARWIN, + staticContext: { a: 1 }, + env: {}, + }, + skipCleanup: true, + }) + ); + }); + }); + describe(BuildStepGlobalContext.deserialize, () => { + it('deserializes global context', () => { + const ctx = BuildStepGlobalContext.deserialize( + { + stepsInternalBuildDirectory: '/m/n/o', + stepById: { + build_ios: { + id: 'build_ios', + executed: true, + outputById: { + build_id: { + id: 'build_id', + stepDisplayName: 'build_ios', + required: true, + value: 'build_id_value', + }, + }, + displayName: 'build_ios', + }, + }, + provider: { + projectSourceDirectory: '/a/b/c', + projectTargetDirectory: '/d/e/f', + defaultWorkingDirectory: '/g/h/i', + buildLogsDirectory: '/j/k/l', + runtimePlatform: BuildRuntimePlatform.DARWIN, + staticContext: { a: 1 } as unknown as JobInterpolationContext, + env: {}, + }, + skipCleanup: true, + }, + createMockLogger() + ); + ctx.markAsCheckedOut(ctx.baseLogger); + expect(ctx.stepsInternalBuildDirectory).toBe('/m/n/o'); + expect(ctx.defaultWorkingDirectory).toBe('/g/h/i'); + expect(ctx.runtimePlatform).toBe(BuildRuntimePlatform.DARWIN); + expect(ctx.skipCleanup).toBe(true); + expect(ctx.projectSourceDirectory).toBe('/a/b/c'); + expect(ctx.projectTargetDirectory).toBe('/d/e/f'); + expect(ctx.buildLogsDirectory).toBe('/j/k/l'); + expect(ctx.staticContext).toEqual({ + a: 1, + steps: { + build_ios: { + outputs: { + build_id: 'build_id_value', + }, + }, + }, + }); + expect(ctx.env).toEqual({}); + expect(ctx.skipCleanup).toBe(true); + }); + }); + describe(BuildStepGlobalContext.prototype.getStepOutputValue, () => { + it('throws an error if the step output references a non-existent step', () => { + const ctx = createGlobalContextMock(); + const error = getError(() => { + ctx.getStepOutputValue('steps.abc.def'); + }); + expect(error).toBeInstanceOf(BuildStepRuntimeError); + expect(error.message).toMatch(/Step "abc" does not exist/); + }); + it('calls getOutputValueByName on the step to get the output value', () => { + const ctx = createGlobalContextMock(); + + const mockStep = mock(); + when(mockStep.id).thenReturn('abc'); + when(mockStep.getOutputValueByName('def')).thenReturn('ghi'); + const step = instance(mockStep); + + ctx.registerStep(step); + expect(ctx.getStepOutputValue('steps.abc.def')).toBe('ghi'); + }); + }); + describe(BuildStepGlobalContext.prototype.stepCtx, () => { + it('returns a BuildStepContext object', () => { + const ctx = createGlobalContextMock(); + expect(ctx.stepCtx({ logger: ctx.baseLogger })).toBeInstanceOf(BuildStepContext); + }); + it('can override logger', () => { + const logger1 = createMockLogger(); + const logger2 = createMockLogger(); + const ctx = createGlobalContextMock({ logger: logger1 }); + const childCtx = ctx.stepCtx({ + logger: logger2, + }); + expect(ctx.baseLogger).toBe(logger1); + expect(childCtx.logger).toBe(logger2); + }); + it('can override working directory', () => { + const ctx = createGlobalContextMock({ + relativeWorkingDirectory: 'apps/mobile', + }); + ctx.markAsCheckedOut(ctx.baseLogger); + + const relativeChildCtx = ctx.stepCtx({ + relativeWorkingDirectory: 'scripts', + logger: ctx.baseLogger, + }); + expect(ctx.defaultWorkingDirectory).not.toBe(relativeChildCtx.workingDirectory); + expect(relativeChildCtx.workingDirectory).toBe( + path.join(ctx.projectTargetDirectory, 'apps/mobile/scripts') + ); + + const absoluteChildCtx = ctx.stepCtx({ + relativeWorkingDirectory: '/apps/web', + logger: ctx.baseLogger, + }); + expect(ctx.defaultWorkingDirectory).not.toBe(absoluteChildCtx.workingDirectory); + expect(absoluteChildCtx.workingDirectory).toBe( + path.join(ctx.projectTargetDirectory, 'apps/web') + ); + }); + }); + describe(BuildStepGlobalContext.prototype.hashFiles, () => { + let tempDir: string; + let ctx: BuildStepGlobalContext; + + beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'hashfiles-test-')); + ctx = createGlobalContextMock({ + projectTargetDirectory: tempDir, + relativeWorkingDirectory: '', + }); + ctx.markAsCheckedOut(ctx.baseLogger); + }); + + afterEach(() => { + fs.rmSync(tempDir, { recursive: true, force: true }); + }); + + it('returns empty string when no files match', () => { + const hash = ctx.hashFiles('nonexistent/**/*.txt'); + expect(hash).toBe(''); + }); + + it('hashes a single file', () => { + const filePath = path.join(tempDir, 'test.txt'); + fs.writeFileSync(filePath, 'test content'); + + const hash = ctx.hashFiles('test.txt'); + + // Verify it matches the expected hash format + const expectedHash = crypto.createHash('sha256'); + const fileHash = crypto.createHash('sha256'); + fileHash.update('test content'); + expectedHash.write(fileHash.digest()); + expectedHash.end(); + + expect(hash).toBe(expectedHash.digest('hex')); + }); + + it('hashes multiple files in deterministic order', () => { + fs.writeFileSync(path.join(tempDir, 'file1.txt'), 'content1'); + fs.writeFileSync(path.join(tempDir, 'file2.txt'), 'content2'); + fs.writeFileSync(path.join(tempDir, 'file3.txt'), 'content3'); + + const hash1 = ctx.hashFiles('*.txt'); + const hash2 = ctx.hashFiles('*.txt'); + + expect(hash1).toBe(hash2); + expect(hash1).not.toBe(''); + }); + + it('produces different hashes for different file contents', () => { + fs.writeFileSync(path.join(tempDir, 'file.txt'), 'content1'); + const hash1 = ctx.hashFiles('file.txt'); + + fs.writeFileSync(path.join(tempDir, 'file.txt'), 'content2'); + const hash2 = ctx.hashFiles('file.txt'); + + expect(hash1).not.toBe(hash2); + }); + + it('works with glob patterns', () => { + const subdir = path.join(tempDir, 'subdir'); + fs.mkdirSync(subdir); + fs.writeFileSync(path.join(tempDir, 'file1.js'), 'code1'); + fs.writeFileSync(path.join(subdir, 'file2.js'), 'code2'); + fs.writeFileSync(path.join(tempDir, 'file.txt'), 'text'); + + const hash = ctx.hashFiles('**/*.js'); + expect(hash).not.toBe(''); + }); + + it('skips files outside workspace', () => { + const outsideDir = fs.mkdtempSync(path.join(os.tmpdir(), 'outside-')); + try { + fs.writeFileSync(path.join(outsideDir, 'outside.txt'), 'outside'); + fs.writeFileSync(path.join(tempDir, 'inside.txt'), 'inside'); + + // This pattern won't match outside files due to glob cwd + const hash = ctx.hashFiles('inside.txt'); + expect(hash).not.toBe(''); + } finally { + fs.rmSync(outsideDir, { recursive: true, force: true }); + } + }); + + it('handles empty files', () => { + fs.writeFileSync(path.join(tempDir, 'empty.txt'), ''); + const hash = ctx.hashFiles('empty.txt'); + + // Should still produce a hash for an empty file + const expectedHash = crypto.createHash('sha256'); + const fileHash = crypto.createHash('sha256'); + fileHash.update(''); + expectedHash.write(fileHash.digest()); + expectedHash.end(); + + expect(hash).toBe(expectedHash.digest('hex')); + }); + + it('supports multiple patterns', () => { + fs.writeFileSync(path.join(tempDir, 'package-lock.json'), 'npm content'); + fs.writeFileSync(path.join(tempDir, 'Gemfile.lock'), 'ruby content'); + fs.writeFileSync(path.join(tempDir, 'other.txt'), 'other'); + + const hash = ctx.hashFiles('**/package-lock.json', '**/Gemfile.lock'); + expect(hash).not.toBe(''); + + // Verify the hash is deterministic + const hash2 = ctx.hashFiles('**/package-lock.json', '**/Gemfile.lock'); + expect(hash).toBe(hash2); + }); + + it('supports exclusion patterns with multiple patterns', () => { + const libDir = path.join(tempDir, 'lib'); + const fooDir = path.join(libDir, 'foo'); + fs.mkdirSync(libDir); + fs.mkdirSync(fooDir); + + fs.writeFileSync(path.join(libDir, 'file1.rb'), 'ruby1'); + fs.writeFileSync(path.join(fooDir, 'file2.rb'), 'ruby2'); + + const hashAll = ctx.hashFiles('lib/**/*.rb'); + const hashExcluded = ctx.hashFiles('lib/**/*.rb', '!lib/foo/*.rb'); + + // The hashes should be different because exclusion removes foo/file2.rb + expect(hashAll).not.toBe(hashExcluded); + expect(hashExcluded).not.toBe(''); + }); + }); +}); diff --git a/packages/steps/src/__tests__/BuildStepInput-test.ts b/packages/steps/src/__tests__/BuildStepInput-test.ts new file mode 100644 index 0000000000..5075a76b6d --- /dev/null +++ b/packages/steps/src/__tests__/BuildStepInput-test.ts @@ -0,0 +1,990 @@ +import { JobInterpolationContext } from '@expo/eas-build-job'; + +import { BuildStepRuntimeError } from '../errors.js'; +import { BuildStep } from '../BuildStep.js'; +import { + BuildStepInput, + BuildStepInputValueTypeName, + makeBuildStepInputByIdMap, +} from '../BuildStepInput.js'; + +import { createGlobalContextMock } from './utils/context.js'; + +describe(BuildStepInput, () => { + test('basic case string', () => { + const ctx = createGlobalContextMock(); + const i = new BuildStepInput(ctx, { + id: 'foo', + stepDisplayName: BuildStep.getDisplayName({ id: 'test1' }), + required: true, + allowedValueTypeName: BuildStepInputValueTypeName.STRING, + }); + i.set('bar'); + expect(i.getValue({ interpolationContext: ctx.getInterpolationContext() })).toBe('bar'); + }); + + test('basic case boolean', () => { + const ctx = createGlobalContextMock(); + const i = new BuildStepInput(ctx, { + id: 'foo', + stepDisplayName: BuildStep.getDisplayName({ id: 'test1' }), + allowedValueTypeName: BuildStepInputValueTypeName.BOOLEAN, + required: true, + }); + i.set(false); + expect(i.getValue({ interpolationContext: ctx.getInterpolationContext() })).toBe(false); + }); + + test('basic case number', () => { + const ctx = createGlobalContextMock(); + const i = new BuildStepInput(ctx, { + id: 'foo', + stepDisplayName: BuildStep.getDisplayName({ id: 'test1' }), + allowedValueTypeName: BuildStepInputValueTypeName.NUMBER, + required: true, + }); + i.set(42); + expect(i.getValue({ interpolationContext: ctx.getInterpolationContext() })).toBe(42); + }); + + test('basic case json', () => { + const ctx = createGlobalContextMock(); + const i = new BuildStepInput(ctx, { + id: 'foo', + stepDisplayName: BuildStep.getDisplayName({ id: 'test1' }), + allowedValueTypeName: BuildStepInputValueTypeName.JSON, + required: true, + }); + i.set({ foo: 'bar' }); + expect(i.getValue({ interpolationContext: ctx.getInterpolationContext() })).toEqual({ + foo: 'bar', + }); + }); + + test('basic case undefined', () => { + const ctx = createGlobalContextMock(); + const i = new BuildStepInput(ctx, { + id: 'foo', + stepDisplayName: BuildStep.getDisplayName({ id: 'test1' }), + required: false, + allowedValueTypeName: BuildStepInputValueTypeName.STRING, + }); + i.set(undefined); + expect(i.getValue({ interpolationContext: ctx.getInterpolationContext() })).toBeUndefined(); + }); + + test('default value string', () => { + const ctx = createGlobalContextMock(); + const i = new BuildStepInput(ctx, { + id: 'foo', + stepDisplayName: BuildStep.getDisplayName({ id: 'test1' }), + defaultValue: 'baz', + allowedValueTypeName: BuildStepInputValueTypeName.STRING, + required: true, + }); + expect(i.getValue({ interpolationContext: ctx.getInterpolationContext() })).toBe('baz'); + }); + + test('default value boolean', () => { + const ctx = createGlobalContextMock(); + const i = new BuildStepInput(ctx, { + id: 'foo', + stepDisplayName: BuildStep.getDisplayName({ id: 'test1' }), + defaultValue: true, + allowedValueTypeName: BuildStepInputValueTypeName.BOOLEAN, + required: true, + }); + expect(i.getValue({ interpolationContext: ctx.getInterpolationContext() })).toBe(true); + }); + + test('default value json', () => { + const ctx = createGlobalContextMock(); + const i = new BuildStepInput(ctx, { + id: 'foo', + stepDisplayName: BuildStep.getDisplayName({ id: 'test1' }), + defaultValue: { foo: 'bar' }, + allowedValueTypeName: BuildStepInputValueTypeName.JSON, + required: true, + }); + expect(i.getValue({ interpolationContext: ctx.getInterpolationContext() })).toEqual({ + foo: 'bar', + }); + }); + + test('context value string', () => { + const ctx = createGlobalContextMock(); + const i = new BuildStepInput(ctx, { + id: 'foo', + stepDisplayName: BuildStep.getDisplayName({ id: 'test1' }), + defaultValue: '${ eas.runtimePlatform }', + required: true, + allowedValueTypeName: BuildStepInputValueTypeName.STRING, + }); + expect(i.getValue({ interpolationContext: ctx.getInterpolationContext() })).toEqual('linux'); + }); + + test('context value string', () => { + const ctx = createGlobalContextMock({ + staticContextContent: { + foo: 'bar', + } as unknown as JobInterpolationContext, + }); + const i = new BuildStepInput(ctx, { + id: 'foo', + stepDisplayName: BuildStep.getDisplayName({ id: 'test1' }), + defaultValue: '${{ foo }}', + required: true, + allowedValueTypeName: BuildStepInputValueTypeName.STRING, + }); + expect(i.getValue({ interpolationContext: ctx.getInterpolationContext() })).toEqual('bar'); + }); + + test('context value string', () => { + const ctx = createGlobalContextMock({ + staticContextContent: { + foo: 'bar', + } as unknown as JobInterpolationContext, + }); + const i = new BuildStepInput(ctx, { + id: 'foo', + stepDisplayName: BuildStep.getDisplayName({ id: 'test1' }), + defaultValue: 'test-${{ foo }}', + required: true, + allowedValueTypeName: BuildStepInputValueTypeName.STRING, + }); + expect(i.getValue({ interpolationContext: ctx.getInterpolationContext() })).toEqual('test-bar'); + }); + + test('context value string', () => { + const ctx = createGlobalContextMock(); + ctx.updateEnv({ + HOME: '/home/test', + }); + const i = new BuildStepInput(ctx, { + id: 'foo', + stepDisplayName: BuildStep.getDisplayName({ id: 'test1' }), + defaultValue: '${ eas.env.HOME }', + required: true, + allowedValueTypeName: BuildStepInputValueTypeName.STRING, + }); + expect(i.getValue({ interpolationContext: ctx.getInterpolationContext() })).toEqual( + '/home/test' + ); + }); + + test('context value string with newline characters', () => { + const ctx = createGlobalContextMock({ + staticContextContent: { + foo: { + bar: 'Line 1\nLine 2\n\nLine 3', + }, + } as unknown as JobInterpolationContext, + }); + const i = new BuildStepInput(ctx, { + id: 'foo', + stepDisplayName: BuildStep.getDisplayName({ id: 'test1' }), + defaultValue: '${ eas.foo.bar }', + required: true, + allowedValueTypeName: BuildStepInputValueTypeName.STRING, + }); + expect(i.getValue({ interpolationContext: ctx.getInterpolationContext() })).toEqual( + 'Line 1\nLine 2\n\nLine 3' + ); + }); + + test('context value string with newline characters', () => { + const ctx = createGlobalContextMock({ + staticContextContent: { + foo: { + bar: 'Line 1\nLine 2\n\nLine 3', + }, + } as unknown as JobInterpolationContext, + }); + const i = new BuildStepInput(ctx, { + id: 'foo', + stepDisplayName: BuildStep.getDisplayName({ id: 'test1' }), + defaultValue: '${{ foo.bar }}', + required: true, + allowedValueTypeName: BuildStepInputValueTypeName.STRING, + }); + expect(i.getValue({ interpolationContext: ctx.getInterpolationContext() })).toEqual( + 'Line 1\nLine 2\n\nLine 3' + ); + }); + + test('context value string with doubly escaped newline characters', () => { + const ctx = createGlobalContextMock({ + staticContextContent: { + foo: { + bar: 'Line 1\\nLine 2\\n\\nLine 3', + }, + } as unknown as JobInterpolationContext, + }); + const i = new BuildStepInput(ctx, { + id: 'foo', + stepDisplayName: BuildStep.getDisplayName({ id: 'test1' }), + defaultValue: '${ eas.foo.bar }', + required: true, + allowedValueTypeName: BuildStepInputValueTypeName.STRING, + }); + expect(i.getValue({ interpolationContext: ctx.getInterpolationContext() })).toEqual( + 'Line 1\nLine 2\n\nLine 3' + ); + }); + + test('context value string with doubly escaped newline characters', () => { + const ctx = createGlobalContextMock({ + staticContextContent: { + foo: { + bar: 'Line 1\\nLine 2\\n\\nLine 3', + }, + } as unknown as JobInterpolationContext, + }); + const i = new BuildStepInput(ctx, { + id: 'foo', + stepDisplayName: BuildStep.getDisplayName({ id: 'test1' }), + defaultValue: '${{ foo.bar }}', + required: true, + allowedValueTypeName: BuildStepInputValueTypeName.STRING, + }); + expect(i.getValue({ interpolationContext: ctx.getInterpolationContext() })).toEqual( + 'Line 1\nLine 2\n\nLine 3' + ); + }); + + test('context value number', () => { + const ctx = createGlobalContextMock({ + staticContextContent: { + foo: { + bar: [ + 1, + 2, + 3, + { + baz: 42, + }, + ], + }, + } as unknown as JobInterpolationContext, + }); + const i = new BuildStepInput(ctx, { + id: 'foo', + stepDisplayName: BuildStep.getDisplayName({ id: 'test1' }), + defaultValue: '${ eas.foo.bar[3].baz }', + allowedValueTypeName: BuildStepInputValueTypeName.NUMBER, + required: true, + }); + expect(i.getValue({ interpolationContext: ctx.getInterpolationContext() })).toEqual(42); + }); + + test('context value number', () => { + const ctx = createGlobalContextMock({ + staticContextContent: { + foo: { + bar: [ + 1, + 2, + 3, + { + baz: 42, + }, + ], + }, + } as unknown as JobInterpolationContext, + }); + const i = new BuildStepInput(ctx, { + id: 'foo', + stepDisplayName: BuildStep.getDisplayName({ id: 'test1' }), + defaultValue: '${{ foo.bar[3].baz }}', + allowedValueTypeName: BuildStepInputValueTypeName.NUMBER, + required: true, + }); + expect(i.getValue({ interpolationContext: ctx.getInterpolationContext() })).toEqual(42); + }); + + test('context value number', () => { + const ctx = createGlobalContextMock({ + staticContextContent: { + foo: { + bar: [ + 1, + 2, + 3, + { + baz: 42, + }, + ], + }, + } as unknown as JobInterpolationContext, + }); + const i = new BuildStepInput(ctx, { + id: 'foo', + stepDisplayName: BuildStep.getDisplayName({ id: 'test1' }), + defaultValue: 'test-${{ foo.bar[3].baz }}', + allowedValueTypeName: BuildStepInputValueTypeName.STRING, + required: true, + }); + expect(i.getValue({ interpolationContext: ctx.getInterpolationContext() })).toEqual('test-42'); + }); + + test('context value boolean', () => { + const ctx = createGlobalContextMock({ + staticContextContent: { + foo: { + bar: [ + 1, + 2, + 3, + { + baz: { + qux: false, + }, + }, + ], + }, + } as unknown as JobInterpolationContext, + }); + const i = new BuildStepInput(ctx, { + id: 'foo', + stepDisplayName: BuildStep.getDisplayName({ id: 'test1' }), + defaultValue: '${ eas.foo.bar[3].baz.qux }', + allowedValueTypeName: BuildStepInputValueTypeName.BOOLEAN, + required: true, + }); + expect(i.getValue({ interpolationContext: ctx.getInterpolationContext() })).toEqual(false); + }); + + test('context value boolean', () => { + const ctx = createGlobalContextMock({ + staticContextContent: { + foo: { + bar: [ + 1, + 2, + 3, + { + baz: { + qux: 'test-123', + }, + }, + ], + }, + } as unknown as JobInterpolationContext, + }); + const i = new BuildStepInput(ctx, { + id: 'foo', + stepDisplayName: BuildStep.getDisplayName({ id: 'test1' }), + defaultValue: '${{ startsWith(foo.bar[3].baz.qux, "test") }}', + allowedValueTypeName: BuildStepInputValueTypeName.BOOLEAN, + required: true, + }); + expect(i.getValue({ interpolationContext: ctx.getInterpolationContext() })).toEqual(true); + }); + + test('context value boolean', () => { + const ctx = createGlobalContextMock({ + staticContextContent: { + foo: { + bar: [ + 1, + 2, + 3, + { + baz: { + qux: false, + }, + }, + ], + }, + } as unknown as JobInterpolationContext, + }); + const i = new BuildStepInput(ctx, { + id: 'foo', + stepDisplayName: BuildStep.getDisplayName({ id: 'test1' }), + defaultValue: '${{ foo.bar[3].baz.qux }}', + allowedValueTypeName: BuildStepInputValueTypeName.BOOLEAN, + required: true, + }); + expect(i.getValue({ interpolationContext: ctx.getInterpolationContext() })).toEqual(false); + }); + + test('context value JSON', () => { + const ctx = createGlobalContextMock({ + staticContextContent: { + foo: { + bar: [ + 1, + 2, + 3, + { + baz: { + qux: false, + }, + }, + ], + }, + } as unknown as JobInterpolationContext, + }); + const i = new BuildStepInput(ctx, { + id: 'foo', + stepDisplayName: BuildStep.getDisplayName({ id: 'test1' }), + defaultValue: '${ eas.foo }', + allowedValueTypeName: BuildStepInputValueTypeName.JSON, + required: true, + }); + expect(i.getValue({ interpolationContext: ctx.getInterpolationContext() })).toMatchObject({ + bar: [1, 2, 3, { baz: { qux: false } }], + }); + }); + + test('context value JSON', () => { + const ctx = createGlobalContextMock({ + staticContextContent: { + foo: { + bar: [ + 1, + 2, + 3, + { + baz: { + qux: false, + }, + }, + ], + }, + } as unknown as JobInterpolationContext, + }); + const i = new BuildStepInput(ctx, { + id: 'foo', + stepDisplayName: BuildStep.getDisplayName({ id: 'test1' }), + defaultValue: '${{ foo }}', + allowedValueTypeName: BuildStepInputValueTypeName.JSON, + required: true, + }); + expect(i.getValue({ interpolationContext: ctx.getInterpolationContext() })).toMatchObject({ + bar: [1, 2, 3, { baz: { qux: false } }], + }); + }); + + test('invalid context value type number', () => { + const ctx = createGlobalContextMock({ + staticContextContent: { + foo: { + bar: [ + 1, + 2, + 3, + { + baz: { + qux: 'ala ma kota', + }, + }, + ], + }, + } as unknown as JobInterpolationContext, + }); + const i = new BuildStepInput(ctx, { + id: 'foo', + stepDisplayName: BuildStep.getDisplayName({ id: 'test1' }), + defaultValue: '${ eas.foo.bar[3].baz.qux }', + allowedValueTypeName: BuildStepInputValueTypeName.NUMBER, + required: true, + }); + expect(() => i.getValue({ interpolationContext: ctx.getInterpolationContext() })).toThrowError( + 'Input parameter "foo" for step "test1" must be of type "number".' + ); + }); + + test('invalid context value type number', () => { + const ctx = createGlobalContextMock({ + staticContextContent: { + foo: { + bar: [ + 1, + 2, + 3, + { + baz: { + qux: 'ala ma kota', + }, + }, + ], + }, + } as unknown as JobInterpolationContext, + }); + const i = new BuildStepInput(ctx, { + id: 'foo', + stepDisplayName: BuildStep.getDisplayName({ id: 'test1' }), + defaultValue: '${{ foo.bar[3].baz.qux }}', + allowedValueTypeName: BuildStepInputValueTypeName.NUMBER, + required: true, + }); + expect(() => i.getValue({ interpolationContext: ctx.getInterpolationContext() })).toThrowError( + 'Input parameter "foo" for step "test1" must be of type "number".' + ); + }); + + test('invalid context value type boolean', () => { + const ctx = createGlobalContextMock({ + staticContextContent: { + foo: { + bar: [ + 1, + 2, + 3, + { + baz: { + qux: 123, + }, + }, + ], + }, + } as unknown as JobInterpolationContext, + }); + const i = new BuildStepInput(ctx, { + id: 'foo', + stepDisplayName: BuildStep.getDisplayName({ id: 'test1' }), + defaultValue: '${ eas.foo.bar[3].baz.qux }', + required: true, + allowedValueTypeName: BuildStepInputValueTypeName.BOOLEAN, + }); + expect(() => i.getValue({ interpolationContext: ctx.getInterpolationContext() })).toThrowError( + 'Input parameter "foo" for step "test1" must be of type "boolean".' + ); + }); + + test('invalid context value type boolean', () => { + const ctx = createGlobalContextMock({ + staticContextContent: { + foo: { + bar: [ + 1, + 2, + 3, + { + baz: { + qux: 123, + }, + }, + ], + }, + } as unknown as JobInterpolationContext, + }); + const i = new BuildStepInput(ctx, { + id: 'foo', + stepDisplayName: BuildStep.getDisplayName({ id: 'test1' }), + defaultValue: '${{ foo.bar[3].baz.qux }}', + required: true, + allowedValueTypeName: BuildStepInputValueTypeName.BOOLEAN, + }); + expect(() => i.getValue({ interpolationContext: ctx.getInterpolationContext() })).toThrowError( + 'Input parameter "foo" for step "test1" must be of type "boolean".' + ); + }); + + test('invalid context value type JSON', () => { + const ctx = createGlobalContextMock({ + staticContextContent: { + foo: { + bar: [ + 1, + 2, + 3, + { + baz: { + qux: 'ala ma kota', + }, + }, + ], + }, + } as unknown as JobInterpolationContext, + }); + const i = new BuildStepInput(ctx, { + id: 'foo', + stepDisplayName: BuildStep.getDisplayName({ id: 'test1' }), + defaultValue: '${ eas.foo.bar[3].baz.qux }', + allowedValueTypeName: BuildStepInputValueTypeName.JSON, + required: true, + }); + expect(() => i.getValue({ interpolationContext: ctx.getInterpolationContext() })).toThrowError( + 'Input parameter "foo" for step "test1" must be of type "json".' + ); + }); + + test('invalid context value type JSON', () => { + const ctx = createGlobalContextMock({ + staticContextContent: { + foo: { + bar: [ + 1, + 2, + 3, + { + baz: { + qux: 'ala ma kota', + }, + }, + ], + }, + } as unknown as JobInterpolationContext, + }); + const i = new BuildStepInput(ctx, { + id: 'foo', + stepDisplayName: BuildStep.getDisplayName({ id: 'test1' }), + defaultValue: '${{ foo.bar[3].baz.qux }}', + allowedValueTypeName: BuildStepInputValueTypeName.JSON, + required: true, + }); + expect(() => i.getValue({ interpolationContext: ctx.getInterpolationContext() })).toThrowError( + 'Input parameter "foo" for step "test1" must be of type "json".' + ); + }); + + test('context values in an object', () => { + const ctx = createGlobalContextMock({ + staticContextContent: { + context_val_1: 'val_1', + context_val_2: { + in_val_1: 'in_val_1', + }, + } as unknown as JobInterpolationContext, + }); + const i = new BuildStepInput(ctx, { + id: 'foo', + stepDisplayName: BuildStep.getDisplayName({ id: 'test1' }), + required: true, + allowedValueTypeName: BuildStepInputValueTypeName.JSON, + }); + i.set({ + foo: 'foo', + bar: '${ eas.context_val_1 }', + baz: { + bazfoo: 'bazfoo', + bazbar: '${ eas.context_val_2.in_val_1 }', + bazbaz: ['bazbaz', '${ eas.context_val_1 }', '${ eas.context_val_2.in_val_1 }'], + }, + }); + expect(i.getValue({ interpolationContext: ctx.getInterpolationContext() })).toEqual({ + foo: 'foo', + bar: 'val_1', + baz: { + bazfoo: 'bazfoo', + bazbar: 'in_val_1', + bazbaz: ['bazbaz', 'val_1', 'in_val_1'], + }, + }); + }); + + test('context values in an object', () => { + const ctx = createGlobalContextMock({ + staticContextContent: { + context_val_1: 'val_1', + context_val_2: { + in_val_1: 'in_val_1', + }, + } as unknown as JobInterpolationContext, + }); + const i = new BuildStepInput(ctx, { + id: 'foo', + stepDisplayName: BuildStep.getDisplayName({ id: 'test1' }), + required: true, + allowedValueTypeName: BuildStepInputValueTypeName.JSON, + }); + i.set({ + foo: 'foo', + bar: '${{ context_val_1 }}', + baz: { + bazfoo: 'bazfoo', + bazbar: '${{ context_val_2.in_val_1 }}', + bazbaz: ['bazbaz', '${{ context_val_1 }}', '${{ context_val_2.in_val_1 }}'], + }, + }); + expect(i.getValue({ interpolationContext: ctx.getInterpolationContext() })).toEqual({ + foo: 'foo', + bar: 'val_1', + baz: { + bazfoo: 'bazfoo', + bazbar: 'in_val_1', + bazbaz: ['bazbaz', 'val_1', 'in_val_1'], + }, + }); + }); + + test('context values in an object with newline characters', () => { + const ctx = createGlobalContextMock({ + staticContextContent: { + context_val_1: 'Line 1\nLine 2\n\nLine 3', + } as unknown as JobInterpolationContext, + }); + const i = new BuildStepInput(ctx, { + id: 'foo', + stepDisplayName: BuildStep.getDisplayName({ id: 'test1' }), + required: true, + allowedValueTypeName: BuildStepInputValueTypeName.JSON, + }); + i.set({ + foo: 'foo', + bar: '${ eas.context_val_1 }', + baz: { + bazfoo: 'bazfoo', + bazbaz: ['bazbaz', '${ eas.context_val_1 }'], + }, + }); + expect(i.getValue({ interpolationContext: ctx.getInterpolationContext() })).toEqual({ + foo: 'foo', + bar: 'Line 1\nLine 2\n\nLine 3', + baz: { + bazfoo: 'bazfoo', + bazbaz: ['bazbaz', 'Line 1\nLine 2\n\nLine 3'], + }, + }); + }); + + test('context values in an object with newline characters', () => { + const ctx = createGlobalContextMock({ + staticContextContent: { + context_val_1: 'Line 1\nLine 2\n\nLine 3', + } as unknown as JobInterpolationContext, + }); + const i = new BuildStepInput(ctx, { + id: 'foo', + stepDisplayName: BuildStep.getDisplayName({ id: 'test1' }), + required: true, + allowedValueTypeName: BuildStepInputValueTypeName.JSON, + }); + i.set({ + foo: 'foo', + bar: '${{ context_val_1 }}', + baz: { + bazfoo: 'bazfoo', + bazbaz: ['bazbaz', '${{ context_val_1 }}'], + }, + }); + expect(i.getValue({ interpolationContext: ctx.getInterpolationContext() })).toEqual({ + foo: 'foo', + bar: 'Line 1\nLine 2\n\nLine 3', + baz: { + bazfoo: 'bazfoo', + bazbaz: ['bazbaz', 'Line 1\nLine 2\n\nLine 3'], + }, + }); + }); + + test('default value number', () => { + const ctx = createGlobalContextMock(); + const i = new BuildStepInput(ctx, { + id: 'foo', + stepDisplayName: BuildStep.getDisplayName({ id: 'test1' }), + defaultValue: 42, + allowedValueTypeName: BuildStepInputValueTypeName.NUMBER, + required: true, + }); + expect(i.getValue({ interpolationContext: ctx.getInterpolationContext() })).toBe(42); + }); + + test('enforces required policy when reading value', () => { + const ctx = createGlobalContextMock(); + const i = new BuildStepInput(ctx, { + id: 'foo', + stepDisplayName: BuildStep.getDisplayName({ id: 'test1' }), + required: true, + allowedValueTypeName: BuildStepInputValueTypeName.STRING, + }); + expect(() => { + // eslint-disable-next-line @typescript-eslint/no-unused-expressions + i.getValue({ interpolationContext: ctx.getInterpolationContext() }); + }).toThrowError( + new BuildStepRuntimeError( + 'Input parameter "foo" for step "test1" is required but it was not set.' + ) + ); + }); + + test('enforces correct value type when reading a value - basic', () => { + const ctx = createGlobalContextMock(); + const i = new BuildStepInput(ctx, { + id: 'foo', + stepDisplayName: BuildStep.getDisplayName({ id: 'test1' }), + required: true, + allowedValueTypeName: BuildStepInputValueTypeName.BOOLEAN, + }); + i.set('bar'); + expect(() => { + // eslint-disable-next-line @typescript-eslint/no-unused-expressions + i.getValue({ interpolationContext: ctx.getInterpolationContext() }); + }).toThrowError( + new BuildStepRuntimeError('Input parameter "foo" for step "test1" must be of type "boolean".') + ); + }); + + test('enforces correct value type when reading a value - reference json', () => { + const ctx = createGlobalContextMock(); + const i = new BuildStepInput(ctx, { + id: 'foo', + required: true, + stepDisplayName: BuildStep.getDisplayName({ id: 'test1' }), + allowedValueTypeName: BuildStepInputValueTypeName.JSON, + }); + i.set('${ eas.runtimePlatform }'); + expect(() => { + // eslint-disable-next-line @typescript-eslint/no-unused-expressions + i.getValue({ interpolationContext: ctx.getInterpolationContext() }); + }).toThrowError('Input parameter "foo" for step "test1" must be of type "json".'); + }); + + test('enforces correct value type when reading a value - reference json', () => { + const ctx = createGlobalContextMock({ + staticContextContent: { + foo: 'bar', + } as unknown as JobInterpolationContext, + }); + const i = new BuildStepInput(ctx, { + id: 'foo', + required: true, + stepDisplayName: BuildStep.getDisplayName({ id: 'test1' }), + allowedValueTypeName: BuildStepInputValueTypeName.JSON, + }); + i.set('${{ foo }}'); + expect(() => { + // eslint-disable-next-line @typescript-eslint/no-unused-expressions + i.getValue({ interpolationContext: ctx.getInterpolationContext() }); + }).toThrowError('Input parameter "foo" for step "test1" must be of type "json".'); + }); + + test('enforces correct value type when reading a value - reference number', () => { + const ctx = createGlobalContextMock(); + const i = new BuildStepInput(ctx, { + id: 'foo', + required: true, + stepDisplayName: BuildStep.getDisplayName({ id: 'test1' }), + allowedValueTypeName: BuildStepInputValueTypeName.NUMBER, + }); + i.set('${ eas.runtimePlatform }'); + expect(() => { + // eslint-disable-next-line @typescript-eslint/no-unused-expressions + i.getValue({ interpolationContext: ctx.getInterpolationContext() }); + }).toThrowError( + new BuildStepRuntimeError('Input parameter "foo" for step "test1" must be of type "number".') + ); + }); + + test('enforces correct value type when reading a value - reference number', () => { + const ctx = createGlobalContextMock({ + staticContextContent: { + foo: 'bar', + } as unknown as JobInterpolationContext, + }); + const i = new BuildStepInput(ctx, { + id: 'foo', + required: true, + stepDisplayName: BuildStep.getDisplayName({ id: 'test1' }), + allowedValueTypeName: BuildStepInputValueTypeName.NUMBER, + }); + i.set('${{ foo }}'); + expect(() => { + // eslint-disable-next-line @typescript-eslint/no-unused-expressions + i.getValue({ interpolationContext: ctx.getInterpolationContext() }); + }).toThrowError( + new BuildStepRuntimeError('Input parameter "foo" for step "test1" must be of type "number".') + ); + }); + + test('enforces correct value type when reading a value - reference boolean', () => { + const ctx = createGlobalContextMock(); + const i = new BuildStepInput(ctx, { + id: 'foo', + required: true, + stepDisplayName: BuildStep.getDisplayName({ id: 'test1' }), + allowedValueTypeName: BuildStepInputValueTypeName.BOOLEAN, + }); + i.set('${ eas.runtimePlatform }'); + expect(() => { + // eslint-disable-next-line @typescript-eslint/no-unused-expressions + i.getValue({ interpolationContext: ctx.getInterpolationContext() }); + }).toThrowError( + new BuildStepRuntimeError('Input parameter "foo" for step "test1" must be of type "boolean".') + ); + }); + + test('enforces correct value type when reading a value - reference boolean', () => { + const ctx = createGlobalContextMock({ + staticContextContent: { + foo: 'bar', + } as unknown as JobInterpolationContext, + }); + const i = new BuildStepInput(ctx, { + id: 'foo', + required: true, + stepDisplayName: BuildStep.getDisplayName({ id: 'test1' }), + allowedValueTypeName: BuildStepInputValueTypeName.BOOLEAN, + }); + i.set('${{ foo }}'); + expect(() => { + // eslint-disable-next-line @typescript-eslint/no-unused-expressions + i.getValue({ interpolationContext: ctx.getInterpolationContext() }); + }).toThrowError( + new BuildStepRuntimeError('Input parameter "foo" for step "test1" must be of type "boolean".') + ); + }); + + test('enforces required policy when setting value', () => { + const ctx = createGlobalContextMock(); + const i = new BuildStepInput(ctx, { + id: 'foo', + stepDisplayName: BuildStep.getDisplayName({ id: 'test1' }), + required: true, + allowedValueTypeName: BuildStepInputValueTypeName.STRING, + }); + expect(() => { + i.set(undefined); + }).toThrowError( + new BuildStepRuntimeError('Input parameter "foo" for step "test1" is required.') + ); + }); +}); + +describe(makeBuildStepInputByIdMap, () => { + it('returns empty object when inputs are undefined', () => { + expect(makeBuildStepInputByIdMap(undefined)).toEqual({}); + }); + + it('returns object with inputs indexed by their ids', () => { + const ctx = createGlobalContextMock(); + const inputs: BuildStepInput[] = [ + new BuildStepInput(ctx, { + id: 'foo1', + stepDisplayName: BuildStep.getDisplayName({ id: 'test1' }), + defaultValue: 'bar1', + required: true, + allowedValueTypeName: BuildStepInputValueTypeName.STRING, + }), + new BuildStepInput(ctx, { + id: 'foo2', + stepDisplayName: BuildStep.getDisplayName({ id: 'test1' }), + defaultValue: 'bar2', + required: true, + allowedValueTypeName: BuildStepInputValueTypeName.STRING, + }), + new BuildStepInput(ctx, { + id: 'foo3', + stepDisplayName: BuildStep.getDisplayName({ id: 'test1' }), + defaultValue: true, + allowedValueTypeName: BuildStepInputValueTypeName.BOOLEAN, + required: true, + }), + ]; + const result = makeBuildStepInputByIdMap(inputs); + expect(Object.keys(result).length).toBe(3); + expect(result.foo1).toBeDefined(); + expect(result.foo2).toBeDefined(); + expect(result.foo1.getValue({ interpolationContext: ctx.getInterpolationContext() })).toBe( + 'bar1' + ); + expect(result.foo2.getValue({ interpolationContext: ctx.getInterpolationContext() })).toBe( + 'bar2' + ); + expect(result.foo3.getValue({ interpolationContext: ctx.getInterpolationContext() })).toBe( + true + ); + }); +}); diff --git a/packages/steps/src/__tests__/BuildStepOutput-test.ts b/packages/steps/src/__tests__/BuildStepOutput-test.ts new file mode 100644 index 0000000000..4df9c6e457 --- /dev/null +++ b/packages/steps/src/__tests__/BuildStepOutput-test.ts @@ -0,0 +1,104 @@ +import { BuildStep } from '../BuildStep.js'; +import { BuildStepOutput, makeBuildStepOutputByIdMap } from '../BuildStepOutput.js'; +import { BuildStepRuntimeError } from '../errors.js'; + +import { createGlobalContextMock } from './utils/context.js'; + +describe(BuildStepOutput, () => { + test('basic case', () => { + const ctx = createGlobalContextMock(); + const o = new BuildStepOutput(ctx, { + id: 'foo', + stepDisplayName: BuildStep.getDisplayName({ id: 'test1' }), + required: true, + }); + o.set('bar'); + expect(o.value).toBe('bar'); + }); + + test('enforces required policy when reading value', () => { + const ctx = createGlobalContextMock(); + const o = new BuildStepOutput(ctx, { + id: 'foo', + stepDisplayName: BuildStep.getDisplayName({ id: 'test1' }), + required: true, + }); + expect(() => { + // eslint-disable-next-line + o.value; + }).toThrowError( + new BuildStepRuntimeError( + 'Output parameter "foo" for step "test1" is required but it was not set.' + ) + ); + }); + + test('enforces required policy when setting value', () => { + const ctx = createGlobalContextMock(); + const i = new BuildStepOutput(ctx, { + id: 'foo', + stepDisplayName: BuildStep.getDisplayName({ id: 'test1' }), + required: true, + }); + expect(() => { + i.set(undefined); + }).toThrowError( + new BuildStepRuntimeError('Output parameter "foo" for step "test1" is required.') + ); + }); + + test('serializes correctly', () => { + const ctx = createGlobalContextMock(); + const o = new BuildStepOutput(ctx, { + id: 'foo', + stepDisplayName: BuildStep.getDisplayName({ id: 'test1' }), + required: true, + }); + o.set('bar'); + expect(o.serialize()).toEqual({ + id: 'foo', + stepDisplayName: BuildStep.getDisplayName({ id: 'test1' }), + required: true, + value: 'bar', + }); + }); + + test('deserializes correctly', () => { + const o = BuildStepOutput.deserialize({ + id: 'foo', + stepDisplayName: BuildStep.getDisplayName({ id: 'test1' }), + required: true, + value: 'bar', + }); + expect(o.id).toBe('foo'); + expect(o.stepDisplayName).toBe(BuildStep.getDisplayName({ id: 'test1' })); + expect(o.required).toBe(true); + expect(o.value).toBe('bar'); + }); +}); + +describe(makeBuildStepOutputByIdMap, () => { + it('returns empty object when inputs are undefined', () => { + expect(makeBuildStepOutputByIdMap(undefined)).toEqual({}); + }); + + it('returns object with outputs indexed by their ids', () => { + const ctx = createGlobalContextMock(); + const outputs: BuildStepOutput[] = [ + new BuildStepOutput(ctx, { + id: 'abc1', + stepDisplayName: BuildStep.getDisplayName({ id: 'test1' }), + required: true, + }), + new BuildStepOutput(ctx, { + id: 'abc2', + stepDisplayName: BuildStep.getDisplayName({ id: 'test1' }), + required: true, + }), + ]; + const result = makeBuildStepOutputByIdMap(outputs); + expect(Object.keys(result).length).toBe(2); + expect(result.abc1).toBeDefined(); + expect(result.abc2).toBeDefined(); + }); +}); diff --git a/packages/steps/src/__tests__/BuildTemporaryFiles-test.ts b/packages/steps/src/__tests__/BuildTemporaryFiles-test.ts new file mode 100644 index 0000000000..7168bd7f7b --- /dev/null +++ b/packages/steps/src/__tests__/BuildTemporaryFiles-test.ts @@ -0,0 +1,43 @@ +import fs from 'fs/promises'; +import os from 'os'; + +import { + cleanUpStepTemporaryDirectoriesAsync, + getTemporaryOutputsDirPath, + saveScriptToTemporaryFileAsync, +} from '../BuildTemporaryFiles.js'; + +import { createGlobalContextMock } from './utils/context.js'; + +describe(saveScriptToTemporaryFileAsync, () => { + it('saves the script in a directory inside os.tmpdir()', async () => { + const ctx = createGlobalContextMock(); + const scriptPath = await saveScriptToTemporaryFileAsync(ctx, 'foo', 'echo 123\necho 456'); + expect(scriptPath.startsWith(os.tmpdir())).toBe(true); + }); + it('saves the script to a temporary file', async () => { + const ctx = createGlobalContextMock(); + const contents = 'echo 123\necho 456'; + const scriptPath = await saveScriptToTemporaryFileAsync(ctx, 'foo', contents); + await expect(fs.readFile(scriptPath, 'utf-8')).resolves.toBe(contents); + }); +}); + +describe(cleanUpStepTemporaryDirectoriesAsync, () => { + it('removes the step temporary directories', async () => { + const ctx = createGlobalContextMock(); + const scriptPath = await saveScriptToTemporaryFileAsync(ctx, 'foo', 'echo 123'); + const outputsPath = getTemporaryOutputsDirPath(ctx, 'foo'); + await fs.mkdir(outputsPath, { recursive: true }); + await expect(fs.stat(scriptPath)).resolves.toBeTruthy(); + await expect(fs.stat(outputsPath)).resolves.toBeTruthy(); + await cleanUpStepTemporaryDirectoriesAsync(ctx, 'foo'); + await expect(fs.stat(scriptPath)).rejects.toThrow(/no such file or directory/); + await expect(fs.stat(outputsPath)).rejects.toThrow(/no such file or directory/); + }); + + it(`doesn't fail if temporary directories don't exist`, async () => { + const ctx = createGlobalContextMock(); + await expect(cleanUpStepTemporaryDirectoriesAsync(ctx, 'foo')).resolves.not.toThrow(); + }); +}); diff --git a/packages/steps/src/__tests__/BuildWorkflow-test.ts b/packages/steps/src/__tests__/BuildWorkflow-test.ts new file mode 100644 index 0000000000..a239125431 --- /dev/null +++ b/packages/steps/src/__tests__/BuildWorkflow-test.ts @@ -0,0 +1,160 @@ +import { instance, mock, verify, when } from 'ts-mockito'; + +import { BuildStep } from '../BuildStep.js'; +import { BuildWorkflow } from '../BuildWorkflow.js'; + +import { createGlobalContextMock } from './utils/context.js'; + +describe(BuildWorkflow, () => { + describe(BuildWorkflow.prototype.executeAsync, () => { + it('executes all steps passed to the constructor', async () => { + const mockBuildStep1 = mock(); + const mockBuildStep2 = mock(); + const mockBuildStep3 = mock(); + const mockBuildStep4 = mock(); + when(mockBuildStep4.shouldExecuteStep()).thenReturn(true); + when(mockBuildStep3.shouldExecuteStep()).thenReturn(true); + when(mockBuildStep2.shouldExecuteStep()).thenReturn(true); + when(mockBuildStep1.shouldExecuteStep()).thenReturn(true); + + const buildSteps: BuildStep[] = [ + instance(mockBuildStep1), + instance(mockBuildStep2), + instance(mockBuildStep3), + ]; + + const ctx = createGlobalContextMock(); + const workflow = new BuildWorkflow(ctx, { buildSteps, buildFunctions: {} }); + await workflow.executeAsync(); + + verify(mockBuildStep1.executeAsync()).once(); + verify(mockBuildStep2.executeAsync()).once(); + verify(mockBuildStep3.executeAsync()).once(); + verify(mockBuildStep4.executeAsync()).never(); + }); + + it('executes steps in correct order', async () => { + const mockBuildStep1 = mock(); + const mockBuildStep2 = mock(); + const mockBuildStep3 = mock(); + when(mockBuildStep3.shouldExecuteStep()).thenReturn(true); + when(mockBuildStep2.shouldExecuteStep()).thenReturn(true); + when(mockBuildStep1.shouldExecuteStep()).thenReturn(true); + + const buildSteps: BuildStep[] = [ + instance(mockBuildStep1), + instance(mockBuildStep3), + instance(mockBuildStep2), + ]; + + const ctx = createGlobalContextMock(); + const workflow = new BuildWorkflow(ctx, { buildSteps, buildFunctions: {} }); + await workflow.executeAsync(); + + verify(mockBuildStep1.executeAsync()).calledBefore(mockBuildStep3.executeAsync()); + verify(mockBuildStep3.executeAsync()).calledBefore(mockBuildStep2.executeAsync()); + verify(mockBuildStep2.executeAsync()).once(); + }); + + it('executes only steps which should be executed', async () => { + const mockBuildStep1 = mock(); + const mockBuildStep2 = mock(); + const mockBuildStep3 = mock(); + const mockBuildStep4 = mock(); + when(mockBuildStep4.shouldExecuteStep()).thenReturn(true); + when(mockBuildStep3.shouldExecuteStep()).thenReturn(false); + when(mockBuildStep2.shouldExecuteStep()).thenReturn(false); + when(mockBuildStep1.shouldExecuteStep()).thenReturn(true); + + const buildSteps: BuildStep[] = [ + instance(mockBuildStep1), + instance(mockBuildStep2), + instance(mockBuildStep3), + instance(mockBuildStep4), + ]; + + const ctx = createGlobalContextMock(); + const workflow = new BuildWorkflow(ctx, { buildSteps, buildFunctions: {} }); + await workflow.executeAsync(); + + verify(mockBuildStep1.executeAsync()).once(); + verify(mockBuildStep2.executeAsync()).never(); + verify(mockBuildStep3.executeAsync()).never(); + verify(mockBuildStep4.executeAsync()).once(); + }); + + it('throws an error if any step fails', async () => { + const mockBuildStep1 = mock(); + const mockBuildStep2 = mock(); + const mockBuildStep3 = mock(); + when(mockBuildStep3.shouldExecuteStep()).thenReturn(false); + when(mockBuildStep2.shouldExecuteStep()).thenReturn(false); + when(mockBuildStep1.shouldExecuteStep()).thenReturn(true); + when(mockBuildStep1.executeAsync()).thenReject(new Error('Step 1 failed')); + + const buildSteps: BuildStep[] = [ + instance(mockBuildStep1), + instance(mockBuildStep2), + instance(mockBuildStep3), + ]; + + const ctx = createGlobalContextMock(); + const workflow = new BuildWorkflow(ctx, { buildSteps, buildFunctions: {} }); + await expect(workflow.executeAsync()).rejects.toThrowError('Step 1 failed'); + + verify(mockBuildStep1.executeAsync()).once(); + verify(mockBuildStep2.executeAsync()).never(); + verify(mockBuildStep3.executeAsync()).never(); + }); + + it('even if previous step fails if next ones should be executed they are executed', async () => { + const mockBuildStep1 = mock(); + const mockBuildStep2 = mock(); + const mockBuildStep3 = mock(); + when(mockBuildStep3.shouldExecuteStep()).thenReturn(true); + when(mockBuildStep2.shouldExecuteStep()).thenReturn(true); + when(mockBuildStep1.shouldExecuteStep()).thenReturn(true); + when(mockBuildStep1.executeAsync()).thenReject(new Error('Step 1 failed')); + + const buildSteps: BuildStep[] = [ + instance(mockBuildStep1), + instance(mockBuildStep2), + instance(mockBuildStep3), + ]; + + const ctx = createGlobalContextMock(); + const workflow = new BuildWorkflow(ctx, { buildSteps, buildFunctions: {} }); + await expect(workflow.executeAsync()).rejects.toThrowError('Step 1 failed'); + + verify(mockBuildStep1.executeAsync()).once(); + verify(mockBuildStep2.executeAsync()).once(); + verify(mockBuildStep3.executeAsync()).once(); + }); + + it('throws always the first error', async () => { + const mockBuildStep1 = mock(); + const mockBuildStep2 = mock(); + const mockBuildStep3 = mock(); + when(mockBuildStep3.shouldExecuteStep()).thenReturn(true); + when(mockBuildStep2.shouldExecuteStep()).thenReturn(true); + when(mockBuildStep1.shouldExecuteStep()).thenReturn(true); + when(mockBuildStep1.executeAsync()).thenReject(new Error('Step 1 failed')); + when(mockBuildStep2.executeAsync()).thenReject(new Error('Step 2 failed')); + when(mockBuildStep3.executeAsync()).thenReject(new Error('Step 3 failed')); + + const buildSteps: BuildStep[] = [ + instance(mockBuildStep1), + instance(mockBuildStep2), + instance(mockBuildStep3), + ]; + + const ctx = createGlobalContextMock(); + const workflow = new BuildWorkflow(ctx, { buildSteps, buildFunctions: {} }); + await expect(workflow.executeAsync()).rejects.toThrowError('Step 1 failed'); + + verify(mockBuildStep1.executeAsync()).once(); + verify(mockBuildStep2.executeAsync()).once(); + verify(mockBuildStep3.executeAsync()).once(); + }); + }); +}); diff --git a/packages/steps/src/__tests__/BuildWorkflowValidator-test.ts b/packages/steps/src/__tests__/BuildWorkflowValidator-test.ts new file mode 100644 index 0000000000..251512437f --- /dev/null +++ b/packages/steps/src/__tests__/BuildWorkflowValidator-test.ts @@ -0,0 +1,522 @@ +import assert from 'assert'; + +import { BuildStep, BuildStepFunction } from '../BuildStep.js'; +import { BuildStepInput, BuildStepInputValueTypeName } from '../BuildStepInput.js'; +import { BuildStepOutput } from '../BuildStepOutput.js'; +import { BuildWorkflow } from '../BuildWorkflow.js'; +import { BuildWorkflowValidator } from '../BuildWorkflowValidator.js'; +import { BuildConfigError, BuildWorkflowError } from '../errors.js'; +import { BuildRuntimePlatform } from '../BuildRuntimePlatform.js'; +import { BuildFunction } from '../BuildFunction.js'; + +import { createGlobalContextMock } from './utils/context.js'; +import { getErrorAsync } from './utils/error.js'; + +describe(BuildWorkflowValidator, () => { + test('non unique step ids', async () => { + const ctx = createGlobalContextMock(); + const workflow = new BuildWorkflow(ctx, { + buildSteps: [ + new BuildStep(ctx, { + id: 'test1', + displayName: BuildStep.getDisplayName({ id: 'test1', command: 'echo 123' }), + command: 'echo 123', + }), + new BuildStep(ctx, { + id: 'test1', + displayName: BuildStep.getDisplayName({ id: 'test1', command: 'echo 456' }), + command: 'echo 456', + }), + new BuildStep(ctx, { + id: 'test1', + displayName: BuildStep.getDisplayName({ id: 'test1', command: 'echo 789' }), + command: 'echo 789', + }), + new BuildStep(ctx, { + id: 'test3', + displayName: BuildStep.getDisplayName({ id: 'test3', command: 'echo 123' }), + command: 'echo 123', + }), + new BuildStep(ctx, { + id: 'test3', + displayName: BuildStep.getDisplayName({ id: 'test3', command: 'echo 456' }), + command: 'echo 456', + }), + ], + buildFunctions: {}, + }); + + const validator = new BuildWorkflowValidator(workflow); + const error = await getErrorAsync(async () => { + await validator.validateAsync(); + }); + expect(error).toBeInstanceOf(BuildWorkflowError); + assert(error instanceof BuildWorkflowError); + expect(error.errors.length).toBe(1); + expect(error.errors[0]).toBeInstanceOf(BuildConfigError); + expect(error.errors[0].message).toBe('Duplicated step IDs: "test1", "test3"'); + }); + test('input set to a non-allowed value', async () => { + const ctx = createGlobalContextMock(); + + const id1 = 'test1'; + const command1 = 'set-output output1 123'; + const displayName1 = BuildStep.getDisplayName({ id: id1, command: command1 }); + + const workflow = new BuildWorkflow(ctx, { + buildSteps: [ + new BuildStep(ctx, { + id: id1, + displayName: displayName1, + inputs: [ + new BuildStepInput(ctx, { + id: 'input1', + stepDisplayName: displayName1, + required: true, + defaultValue: '3', + allowedValues: ['1', '2'], + allowedValueTypeName: BuildStepInputValueTypeName.STRING, + }), + new BuildStepInput(ctx, { + id: 'input2', + stepDisplayName: displayName1, + required: true, + defaultValue: '3', + allowedValues: [true, false], + allowedValueTypeName: BuildStepInputValueTypeName.STRING, + }), + ], + command: command1, + }), + ], + buildFunctions: {}, + }); + + const validator = new BuildWorkflowValidator(workflow); + const error = await getErrorAsync(async () => { + await validator.validateAsync(); + }); + expect(error).toBeInstanceOf(BuildWorkflowError); + assert(error instanceof BuildWorkflowError); + expect(error.errors.length).toBe(2); + expect(error.errors[0]).toBeInstanceOf(BuildConfigError); + expect(error.errors[0].message).toBe( + 'Input parameter "input1" for step "test1" is set to "3" which is not one of the allowed values: "1", "2".' + ); + expect(error.errors[1].message).toBe( + 'Input parameter "input2" for step "test1" is set to "3" which is not one of the allowed values: "true", "false".' + ); + }); + test('required function input without default value and value passed to step', async () => { + const ctx = createGlobalContextMock(); + + const func = new BuildFunction({ + id: 'say_hi', + inputProviders: [ + BuildStepInput.createProvider({ + id: 'id1', + required: true, + allowedValueTypeName: BuildStepInputValueTypeName.STRING, + }), + ], + command: 'echo "hi"', + }); + + const workflow = new BuildWorkflow(ctx, { + buildSteps: [ + func.createBuildStepFromFunctionCall(ctx, { + id: 'step_id', + callInputs: {}, + }), + ], + buildFunctions: { + say_hi: func, + }, + }); + + const validator = new BuildWorkflowValidator(workflow); + const error = await getErrorAsync(async () => { + await validator.validateAsync(); + }); + expect(error).toBeInstanceOf(BuildWorkflowError); + expect((error as BuildWorkflowError).errors[0].message).toBe( + 'Input parameter "id1" for step "step_id" is required but it was not set.' + ); + }); + test('invalid input type passed to step', async () => { + const ctx = createGlobalContextMock(); + + const func = new BuildFunction({ + id: 'say_hi', + inputProviders: [ + BuildStepInput.createProvider({ + id: 'id1', + required: true, + allowedValueTypeName: BuildStepInputValueTypeName.STRING, + }), + BuildStepInput.createProvider({ + id: 'id2', + allowedValueTypeName: BuildStepInputValueTypeName.NUMBER, + required: true, + }), + BuildStepInput.createProvider({ + id: 'id3', + allowedValueTypeName: BuildStepInputValueTypeName.JSON, + required: true, + }), + BuildStepInput.createProvider({ + id: 'id4', + allowedValueTypeName: BuildStepInputValueTypeName.NUMBER, + required: true, + }), + BuildStepInput.createProvider({ + id: 'id5', + allowedValueTypeName: BuildStepInputValueTypeName.NUMBER, + required: true, + }), + BuildStepInput.createProvider({ + id: 'id6', + allowedValueTypeName: BuildStepInputValueTypeName.NUMBER, + required: true, + }), + ], + command: 'echo "hi"', + }); + + const workflow = new BuildWorkflow(ctx, { + buildSteps: [ + func.createBuildStepFromFunctionCall(ctx, { + id: 'step_id', + callInputs: { + id1: 123, + id2: { + a: 1, + b: 2, + }, + id3: 'abc', + id4: '${ steps.step_id.output1 }', + id5: '${ eas.job.version.buildNumber }', + id6: '${ wrong.aaa }', + }, + }), + ], + buildFunctions: { + say_hi: func, + }, + }); + + const validator = new BuildWorkflowValidator(workflow); + const error = await getErrorAsync(async () => { + await validator.validateAsync(); + }); + expect(error).toBeInstanceOf(BuildWorkflowError); + expect((error as BuildWorkflowError).errors[0].message).toBe( + 'Input parameter "id1" for step "step_id" is set to "123" which is not of type "string" or is not step or context reference.' + ); + expect((error as BuildWorkflowError).errors[1].message).toBe( + 'Input parameter "id2" for step "step_id" is set to "{"a":1,"b":2}" which is not of type "number" or is not step or context reference.' + ); + expect((error as BuildWorkflowError).errors[2].message).toBe( + 'Input parameter "id3" for step "step_id" is set to "abc" which is not of type "json" or is not step or context reference.' + ); + expect((error as BuildWorkflowError).errors[3].message).toBe( + 'Input parameter "id6" for step "step_id" is set to "${ wrong.aaa }" which is not of type "number" or is not step or context reference.' + ); + }); + test('output from future step', async () => { + const ctx = createGlobalContextMock(); + + const id1 = 'test1'; + const command1 = 'set-output output1 123'; + const displayName1 = BuildStep.getDisplayName({ id: id1, command: command1 }); + + const id2 = 'test2'; + const command2 = 'set-output output1 123'; + const displayName2 = BuildStep.getDisplayName({ id: id2, command: command2 }); + + const workflow = new BuildWorkflow(ctx, { + buildSteps: [ + new BuildStep(ctx, { + id: id1, + displayName: displayName1, + inputs: [ + new BuildStepInput(ctx, { + id: 'input1', + stepDisplayName: displayName1, + required: true, + defaultValue: '${ steps.test2.output1 }', + allowedValueTypeName: BuildStepInputValueTypeName.STRING, + }), + ], + command: command1, + }), + new BuildStep(ctx, { + id: id2, + displayName: displayName2, + outputs: [ + new BuildStepOutput(ctx, { + id: 'output1', + stepDisplayName: displayName2, + required: true, + }), + ], + command: command2, + }), + ], + buildFunctions: {}, + }); + + const validator = new BuildWorkflowValidator(workflow); + const error = await getErrorAsync(async () => { + await validator.validateAsync(); + }); + expect(error).toBeInstanceOf(BuildWorkflowError); + assert(error instanceof BuildWorkflowError); + expect(error.errors.length).toBe(1); + expect(error.errors[0]).toBeInstanceOf(BuildConfigError); + expect(error.errors[0].message).toBe( + 'Input parameter "input1" for step "test1" uses an expression that references an output parameter from the future step "test2".' + ); + }); + test('output from non-existent step', async () => { + const id = 'test2'; + const command = 'echo ${ inputs.input1 }'; + const displayName = BuildStep.getDisplayName({ id, command }); + + const ctx = createGlobalContextMock(); + const workflow = new BuildWorkflow(ctx, { + buildSteps: [ + new BuildStep(ctx, { + id, + displayName, + inputs: [ + new BuildStepInput(ctx, { + id: 'input1', + stepDisplayName: displayName, + required: true, + defaultValue: '${ steps.test1.output1 }', + allowedValueTypeName: BuildStepInputValueTypeName.STRING, + }), + ], + command, + }), + ], + buildFunctions: {}, + }); + + const validator = new BuildWorkflowValidator(workflow); + const error = await getErrorAsync(async () => { + await validator.validateAsync(); + }); + expect(error).toBeInstanceOf(BuildWorkflowError); + assert(error instanceof BuildWorkflowError); + expect(error.errors.length).toBe(1); + expect(error.errors[0]).toBeInstanceOf(BuildConfigError); + expect(error.errors[0].message).toBe( + 'Input parameter "input1" for step "test2" uses an expression that references an output parameter from a non-existent step "test1".' + ); + }); + test('undefined output', async () => { + const id1 = 'test1'; + const command1 = 'set-output output1 123'; + const displayName1 = BuildStep.getDisplayName({ id: id1, command: command1 }); + + const id2 = 'test2'; + const command2 = 'echo ${ inputs.input1 }'; + const displayName2 = BuildStep.getDisplayName({ id: id2, command: command2 }); + + const id3 = 'test3'; + const command3 = 'echo ${ inputs.input1 }'; + const displayName3 = BuildStep.getDisplayName({ id: id3, command: command3 }); + + const ctx = createGlobalContextMock(); + const workflow = new BuildWorkflow(ctx, { + buildSteps: [ + new BuildStep(ctx, { + id: id1, + displayName: displayName1, + outputs: [ + new BuildStepOutput(ctx, { + id: 'output1', + stepDisplayName: displayName1, + required: true, + }), + ], + command: command1, + }), + new BuildStep(ctx, { + id: id2, + displayName: displayName2, + inputs: [ + new BuildStepInput(ctx, { + id: 'input1', + stepDisplayName: displayName2, + required: true, + defaultValue: '${ steps.test1.output1 }', + allowedValueTypeName: BuildStepInputValueTypeName.STRING, + }), + ], + command: command2, + }), + new BuildStep(ctx, { + id: id3, + displayName: displayName3, + inputs: [ + new BuildStepInput(ctx, { + id: 'input2', + stepDisplayName: displayName3, + required: true, + defaultValue: '${ steps.test2.output2 }', + allowedValueTypeName: BuildStepInputValueTypeName.STRING, + }), + ], + command: command3, + }), + ], + buildFunctions: {}, + }); + + const validator = new BuildWorkflowValidator(workflow); + const error = await getErrorAsync(async () => { + await validator.validateAsync(); + }); + expect(error).toBeInstanceOf(BuildWorkflowError); + assert(error instanceof BuildWorkflowError); + expect(error.errors.length).toBe(1); + expect(error.errors[0]).toBeInstanceOf(BuildConfigError); + expect(error.errors[0].message).toBe( + 'Input parameter "input2" for step "test3" uses an expression that references an undefined output parameter "output2" from step "test2".' + ); + }); + test('multiple config errors', async () => { + const id1 = 'test1'; + const command1 = 'set-output output1 123'; + const displayName1 = BuildStep.getDisplayName({ id: id1, command: command1 }); + + const id2 = 'test2'; + const command2 = 'echo ${ inputs.input1 }'; + const displayName2 = BuildStep.getDisplayName({ id: id2, command: command2 }); + + const id3 = 'test3'; + const command3 = 'echo ${ inputs.input1 }'; + const displayName3 = BuildStep.getDisplayName({ id: id3, command: command3 }); + + const ctx = createGlobalContextMock(); + const workflow = new BuildWorkflow(ctx, { + buildSteps: [ + new BuildStep(ctx, { + id: id1, + displayName: displayName1, + outputs: [ + new BuildStepOutput(ctx, { + id: 'output1', + stepDisplayName: displayName1, + required: true, + }), + ], + command: command1, + }), + new BuildStep(ctx, { + id: id2, + displayName: displayName2, + inputs: [ + new BuildStepInput(ctx, { + id: 'input1', + stepDisplayName: displayName2, + required: true, + defaultValue: '${ steps.test4.output1 }', + allowedValueTypeName: BuildStepInputValueTypeName.STRING, + }), + ], + command: command2, + }), + new BuildStep(ctx, { + id: id3, + displayName: displayName3, + inputs: [ + new BuildStepInput(ctx, { + id: 'input2', + stepDisplayName: displayName3, + required: true, + defaultValue: '${ steps.test2.output2 }', + allowedValueTypeName: BuildStepInputValueTypeName.STRING, + }), + ], + command: 'echo ${ inputs.input2 }', + }), + ], + buildFunctions: {}, + }); + + const validator = new BuildWorkflowValidator(workflow); + const error = await getErrorAsync(async () => { + await validator.validateAsync(); + }); + expect(error).toBeInstanceOf(BuildWorkflowError); + assert(error instanceof BuildWorkflowError); + expect(error.errors.length).toBe(2); + expect(error.errors[0]).toBeInstanceOf(BuildConfigError); + expect(error.errors[0].message).toBe( + 'Input parameter "input1" for step "test2" uses an expression that references an output parameter from a non-existent step "test4".' + ); + expect(error.errors[1]).toBeInstanceOf(BuildConfigError); + expect(error.errors[1].message).toBe( + 'Input parameter "input2" for step "test3" uses an expression that references an undefined output parameter "output2" from step "test2".' + ); + }); + test('unallowed platform for build step', async () => { + const id = 'test'; + const displayName = BuildStep.getDisplayName({ id }); + const fn: BuildStepFunction = () => {}; + + const ctx = createGlobalContextMock({ runtimePlatform: BuildRuntimePlatform.LINUX }); + const workflow = new BuildWorkflow(ctx, { + buildSteps: [ + new BuildStep(ctx, { + id, + displayName, + fn, + supportedRuntimePlatforms: [BuildRuntimePlatform.DARWIN], + }), + ], + buildFunctions: {}, + }); + + const validator = new BuildWorkflowValidator(workflow); + const error = await getErrorAsync(async () => { + await validator.validateAsync(); + }); + expect(error).toBeInstanceOf(BuildWorkflowError); + assert(error instanceof BuildWorkflowError); + expect(error.errors.length).toBe(1); + expect(error.errors[0]).toBeInstanceOf(BuildConfigError); + expect(error.errors[0].message).toBe( + `Step "${displayName}" is not allowed on platform "${BuildRuntimePlatform.LINUX}". Allowed platforms for this step are: "${BuildRuntimePlatform.DARWIN}".` + ); + }); + + test('non-existing custom function module', async () => { + const ctx = createGlobalContextMock({ runtimePlatform: BuildRuntimePlatform.LINUX }); + const workflow = new BuildWorkflow(ctx, { + buildSteps: [], + buildFunctions: { + test: new BuildFunction({ + id: 'test', + customFunctionModulePath: '/non/existent/module', + }), + }, + }); + + const validator = new BuildWorkflowValidator(workflow); + const error = await getErrorAsync(async () => { + await validator.validateAsync(); + }); + assert(error instanceof BuildWorkflowError); + expect(error).toBeInstanceOf(BuildWorkflowError); + expect(error.errors.length).toBe(1); + expect(error.errors[0]).toBeInstanceOf(BuildConfigError); + expect(error.errors[0].message).toBe( + `Custom function module path "/non/existent/module" for function "test" does not exist.` + ); + }); +}); diff --git a/packages/steps/src/__tests__/StepsConfigParser-test.ts b/packages/steps/src/__tests__/StepsConfigParser-test.ts new file mode 100644 index 0000000000..9a33d060ea --- /dev/null +++ b/packages/steps/src/__tests__/StepsConfigParser-test.ts @@ -0,0 +1,388 @@ +import path from 'path'; +import assert from 'node:assert'; + +import { BuildFunction } from '../BuildFunction.js'; +import { BuildFunctionGroup } from '../BuildFunctionGroup.js'; +import { BuildWorkflow } from '../BuildWorkflow.js'; +import { BuildConfigError, BuildStepRuntimeError } from '../errors.js'; +import { StepsConfigParser } from '../StepsConfigParser.js'; +import { BuildStepInput, BuildStepInputValueTypeName } from '../BuildStepInput.js'; + +import { createGlobalContextMock } from './utils/context.js'; +import { getError } from './utils/error.js'; +import { UUID_REGEX } from './utils/uuid.js'; + +describe(StepsConfigParser, () => { + describe('constructor', () => { + it('throws if provided external functions with duplicated IDs', () => { + const ctx = createGlobalContextMock(); + const error = getError(() => { + // eslint-disable-next-line no-new + new StepsConfigParser(ctx, { + steps: [], + externalFunctions: [ + new BuildFunction({ id: 'abc', command: 'echo 123' }), + new BuildFunction({ id: 'abc', command: 'echo 456' }), + ], + }); + }); + expect(error).toBeInstanceOf(BuildConfigError); + expect(error.message).toMatch(/Provided external functions with duplicated IDs/); + }); + + it('throws if provided external function groups with duplicated IDs', () => { + const ctx = createGlobalContextMock(); + const error = getError(() => { + // eslint-disable-next-line no-new + new StepsConfigParser(ctx, { + steps: [ + { + run: 'test', + }, + { + uses: 'eas/build', + }, + ], + externalFunctionGroups: [ + new BuildFunctionGroup({ + id: 'abc', + namespace: 'test', + createBuildStepsFromFunctionGroupCall: () => [], + }), + new BuildFunctionGroup({ + id: 'abc', + namespace: 'test', + createBuildStepsFromFunctionGroupCall: () => [], + }), + ], + }); + }); + expect(error).toBeInstanceOf(BuildConfigError); + expect(error.message).toMatch(/Provided external function groups with duplicated IDs/); + }); + + it(`doesn't throw if provided external functions don't have duplicated IDs`, () => { + const ctx = createGlobalContextMock(); + expect(() => { + // eslint-disable-next-line no-new + new StepsConfigParser(ctx, { + steps: [ + { + run: 'test', + }, + { + uses: 'eas/build', + }, + ], + externalFunctions: [ + new BuildFunction({ namespace: 'a', id: 'abc', command: 'echo 123' }), + new BuildFunction({ namespace: 'b', id: 'abc', command: 'echo 456' }), + ], + }); + }).not.toThrow(); + }); + + it(`doesn't throw if provided external function groups don't have duplicated IDs`, () => { + const ctx = createGlobalContextMock(); + expect(() => { + // eslint-disable-next-line no-new + new StepsConfigParser(ctx, { + steps: [ + { + run: 'test', + }, + { + uses: 'eas/build', + }, + ], + externalFunctionGroups: [ + new BuildFunctionGroup({ + id: 'abc', + namespace: 'test', + createBuildStepsFromFunctionGroupCall: () => [], + }), + new BuildFunctionGroup({ + id: 'abcd', + namespace: 'test', + createBuildStepsFromFunctionGroupCall: () => [], + }), + ], + }); + }).not.toThrow(); + }); + }); + + describe(StepsConfigParser.prototype.parseAsync, () => { + it('throws an error when calling non-existent function', async () => { + const ctx = createGlobalContextMock(); + const parser = new StepsConfigParser(ctx, { + steps: [ + { + run: 'test', + }, + { + uses: 'eas/build', + }, + ], + }); + await expect(parser.parseAsync()).rejects.toThrow( + 'Calling non-existent functions: "eas/build".' + ); + }); + + it('throws an error if steps are empty array', async () => { + const ctx = createGlobalContextMock(); + const parser = new StepsConfigParser(ctx, { + steps: [], + }); + await expect(parser.parseAsync()).rejects.toThrow( + 'Too small: expected array to have >=1 items' + ); + }); + + it('returns a BuildWorkflow object', async () => { + const ctx = createGlobalContextMock(); + const parser = new StepsConfigParser(ctx, { + steps: [ + { + run: 'test', + }, + { + uses: 'eas/build', + }, + ], + externalFunctionGroups: [ + new BuildFunctionGroup({ + id: 'build', + namespace: 'eas', + createBuildStepsFromFunctionGroupCall: () => [], + }), + ], + }); + const result = await parser.parseAsync(); + expect(result).toBeInstanceOf(BuildWorkflow); + }); + + it('parses steps and external functions into build workflow with steps', async () => { + const ctx = createGlobalContextMock(); + const parser = new StepsConfigParser(ctx, { + steps: [ + { + run: 'command1', + }, + { + uses: 'eas/build', + }, + { + id: 'step3', + name: 'Step 3', + run: 'command2', + shell: 'sh', + working_directory: 'dir', + env: { + KEY1: 'value1', + }, + outputs: [ + { + name: 'my_output', + required: true, + }, + { + name: 'my_optional_output', + required: false, + }, + { + name: 'my_optional_output_without_required', + }, + ], + if: '${ always() }', + }, + { + id: 'step4', + name: 'Step 4', + with: { + arg1: 'value1', + arg2: 2, + arg3: { + key1: 'value1', + key2: ['value1'], + }, + arg4: '${ step3.my_output }', + }, + uses: 'eas/checkout', + if: '${ ctx.job.platform } == "android"', + working_directory: 'dir', + env: { + KEY2: 'value2', + }, + }, + ], + externalFunctionGroups: [ + new BuildFunctionGroup({ + id: 'build', + namespace: 'eas', + createBuildStepsFromFunctionGroupCall: () => [ + new BuildFunction({ + id: 'func', + fn: () => { + console.log('step2'); + }, + }).createBuildStepFromFunctionCall(ctx, { + id: 'step2', + workingDirectory: 'test', + env: { + a: 'b', + }, + }), + ], + }), + ], + externalFunctions: [ + new BuildFunction({ + id: 'checkout', + namespace: 'eas', + fn: () => { + console.log('checkout'); + }, + inputProviders: [ + BuildStepInput.createProvider({ + id: 'arg1', + allowedValueTypeName: BuildStepInputValueTypeName.STRING, + required: true, + }), + BuildStepInput.createProvider({ + id: 'arg2', + allowedValueTypeName: BuildStepInputValueTypeName.NUMBER, + required: true, + }), + BuildStepInput.createProvider({ + id: 'arg3', + allowedValueTypeName: BuildStepInputValueTypeName.JSON, + required: true, + }), + BuildStepInput.createProvider({ + id: 'arg4', + allowedValueTypeName: BuildStepInputValueTypeName.STRING, + required: true, + }), + ], + }), + ], + }); + const result = await parser.parseAsync(); + expect(result.buildSteps).toHaveLength(4); + + const step1 = result.buildSteps[0]; + expect(step1.id).toMatch(UUID_REGEX); + expect(step1.name).toBeUndefined(); + expect(step1.command).toBe('command1'); + expect(step1.shell).toBe('/bin/bash -eo pipefail'); + expect(step1.fn).toBeUndefined(); + expect(step1.ctx.workingDirectory).toBe(ctx.defaultWorkingDirectory); + expect(step1.stepEnvOverrides).toEqual({}); + expect(step1.inputs).toBeUndefined(); + expect(step1.outputById).toStrictEqual({}); + expect(step1.ifCondition).toBeUndefined(); + + const step2 = result.buildSteps[1]; + expect(step2.id).toEqual('step2'); + expect(step2.name).toBeUndefined(); + expect(step2.command).toBeUndefined(); + expect(step2.fn).toBeDefined(); + expect(step2.ctx.workingDirectory).toBe(path.join(ctx.defaultWorkingDirectory, 'test')); + expect(step2.stepEnvOverrides).toMatchObject({ + a: 'b', + }); + expect(step2.inputs).toBeUndefined(); + expect(step2.outputById).toStrictEqual({}); + expect(step2.ifCondition).toBeUndefined(); + + const step3 = result.buildSteps[2]; + expect(step3.id).toEqual('step3'); + expect(step3.name).toEqual('Step 3'); + expect(step3.command).toBe('command2'); + expect(step3.shell).toBe('sh'); + expect(step3.fn).toBeUndefined(); + expect(step3.ctx.workingDirectory).toBe(path.join(ctx.defaultWorkingDirectory, 'dir')); + expect(step3.stepEnvOverrides).toMatchObject({ + KEY1: 'value1', + }); + expect(step3.inputs).toBeUndefined(); + expect(step3.outputById).toBeDefined(); + expect(Object.keys(step3.outputById)).toHaveLength(3); + assert(step3.outputById); + const { + my_output: output1, + my_optional_output: output2, + my_optional_output_without_required: output3, + } = step3.outputById; + expect(output1.id).toBe('my_output'); + expect(output1.required).toBe(true); + expect(output2.id).toBe('my_optional_output'); + expect(output2.required).toBe(false); + expect(output3.id).toBe('my_optional_output_without_required'); + expect(output3.required).toBe(true); + expect(step3.ifCondition).toBe('${ always() }'); + + const step4 = result.buildSteps[3]; + expect(step4.id).toEqual('step4'); + expect(step4.name).toEqual('Step 4'); + expect(step4.command).toBeUndefined(); + expect(step4.fn).toBeDefined(); + expect(step4.ctx.workingDirectory).toBe(path.join(ctx.defaultWorkingDirectory, 'dir')); + expect(step4.stepEnvOverrides).toMatchObject({ + KEY2: 'value2', + }); + expect(step4.inputs).toBeDefined(); + assert(step4.inputs); + const [input1, input2, input3, input4] = step4.inputs; + expect(input1.id).toBe('arg1'); + expect( + input1.getValue({ + interpolationContext: ctx.getInterpolationContext(), + }) + ).toBe('value1'); + expect(input1.allowedValueTypeName).toBe(BuildStepInputValueTypeName.STRING); + expect(input1.allowedValues).toBeUndefined(); + expect(input1.defaultValue).toBeUndefined(); + expect(input1.rawValue).toBe('value1'); + expect(input1.required).toBe(true); + expect(input2.id).toBe('arg2'); + expect( + input2.getValue({ + interpolationContext: ctx.getInterpolationContext(), + }) + ).toBe(2); + expect(input2.allowedValueTypeName).toBe(BuildStepInputValueTypeName.NUMBER); + expect(input2.allowedValues).toBeUndefined(); + expect(input2.defaultValue).toBeUndefined(); + expect(input2.rawValue).toBe(2); + expect(input2.required).toBe(true); + expect(input3.id).toBe('arg3'); + expect( + input3.getValue({ + interpolationContext: ctx.getInterpolationContext(), + }) + ).toMatchObject({ + key1: 'value1', + key2: ['value1'], + }); + expect(input3.allowedValueTypeName).toBe(BuildStepInputValueTypeName.JSON); + expect(input3.allowedValues).toBeUndefined(); + expect(input3.defaultValue).toBeUndefined(); + expect(input3.rawValue).toMatchObject({ + key1: 'value1', + key2: ['value1'], + }); + expect(input3.required).toBe(true); + expect(input4.id).toBe('arg4'); + expect(input4.allowedValueTypeName).toBe(BuildStepInputValueTypeName.STRING); + expect(input4.allowedValues).toBeUndefined(); + expect(input4.defaultValue).toBeUndefined(); + expect(input4.rawValue).toBe('${ step3.my_output }'); + expect(input4.required).toBe(true); + expect(step4.outputById).toStrictEqual({}); + expect(step4.ifCondition).toBe('${ ctx.job.platform } == "android"'); + }); + }); +}); diff --git a/packages/steps/src/__tests__/__snapshots__/BuildConfigParser-test.ts.snap b/packages/steps/src/__tests__/__snapshots__/BuildConfigParser-test.ts.snap new file mode 100644 index 0000000000..8870b11fac --- /dev/null +++ b/packages/steps/src/__tests__/__snapshots__/BuildConfigParser-test.ts.snap @@ -0,0 +1,23 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`BuildConfigParser parseAsync parses outputs 1`] = ` +"set-output first_name Brent +set-output last_name Vatne +" +`; + +exports[`BuildConfigParser parseAsync parses outputs 2`] = ` +"set-output first_name Dominik +set-output last_name Sokal +set-output nickname dsokal +" +`; + +exports[`BuildConfigParser parseAsync parses steps from the build workflow 1`] = ` +"echo "H" +echo "E" +echo "L" +echo "L" +echo "O" +" +`; diff --git a/packages/steps/src/__tests__/fixtures/build-darwin-functions.yml b/packages/steps/src/__tests__/fixtures/build-darwin-functions.yml new file mode 100644 index 0000000000..8376454ee4 --- /dev/null +++ b/packages/steps/src/__tests__/fixtures/build-darwin-functions.yml @@ -0,0 +1,11 @@ +import: + - functions-with-platforms-property-darwin.yml + +build: + steps: + - say_hi_darwin: + inputs: + name: Brent + - say_bye_darwin: + inputs: + name: Brent diff --git a/packages/steps/src/__tests__/fixtures/build-linux-and-darwin-functions.yml b/packages/steps/src/__tests__/fixtures/build-linux-and-darwin-functions.yml new file mode 100644 index 0000000000..b71b64e886 --- /dev/null +++ b/packages/steps/src/__tests__/fixtures/build-linux-and-darwin-functions.yml @@ -0,0 +1,11 @@ +import: + - functions-with-platforms-property-darwin-and-linux.yml + +build: + steps: + - say_hi_linux_and_darwin: + inputs: + name: Dominik + - say_bye_linux_and_darwin: + inputs: + name: Dominik diff --git a/packages/steps/src/__tests__/fixtures/build-linux-functions.yml b/packages/steps/src/__tests__/fixtures/build-linux-functions.yml new file mode 100644 index 0000000000..00b830d49c --- /dev/null +++ b/packages/steps/src/__tests__/fixtures/build-linux-functions.yml @@ -0,0 +1,11 @@ +import: + - functions-with-platforms-property-linux.yml + +build: + steps: + - say_hi_linux: + inputs: + name: Wojtek + - say_bye_linux: + inputs: + name: Wojtek diff --git a/packages/steps/src/__tests__/fixtures/build-with-import-cycle.yml b/packages/steps/src/__tests__/fixtures/build-with-import-cycle.yml new file mode 100644 index 0000000000..a9a69616e7 --- /dev/null +++ b/packages/steps/src/__tests__/fixtures/build-with-import-cycle.yml @@ -0,0 +1,9 @@ +import: + - functions-import-cycle.yml + +build: + name: Import! + steps: + - say_hi: + inputs: + name: Dominik diff --git a/packages/steps/src/__tests__/fixtures/build-with-import.yml b/packages/steps/src/__tests__/fixtures/build-with-import.yml new file mode 100644 index 0000000000..89d543581e --- /dev/null +++ b/packages/steps/src/__tests__/fixtures/build-with-import.yml @@ -0,0 +1,13 @@ +import: + - functions-file-1.yml + +build: + name: Import! + steps: + - say_hi: + env: + ENV1: value1 + ENV2: value2 + inputs: + name: Dominik + - say_hi_wojtek diff --git a/packages/steps/src/__tests__/fixtures/build.yml b/packages/steps/src/__tests__/fixtures/build.yml new file mode 100644 index 0000000000..bc81b47045 --- /dev/null +++ b/packages/steps/src/__tests__/fixtures/build.yml @@ -0,0 +1,31 @@ +build: + name: Foobar + steps: + - run: echo "Hi!" + - run: + name: Say HELLO + command: | + echo "H" + echo "E" + echo "L" + echo "L" + echo "O" + - run: + id: id_2137 + command: echo "Step with an ID" + env: + FOO: bar + BAR: baz + - run: + name: List files + working_directory: relative/path/to/files + command: ls -la + - run: + name: List files in another directory + working_directory: /home/dsokal + command: ls -la + - run: + if: ${ always() } + name: Use non-default shell + shell: /nib/hsab + command: echo 123 diff --git a/packages/steps/src/__tests__/fixtures/external-function-groups.yml b/packages/steps/src/__tests__/fixtures/external-function-groups.yml new file mode 100644 index 0000000000..435cf9dc8a --- /dev/null +++ b/packages/steps/src/__tests__/fixtures/external-function-groups.yml @@ -0,0 +1,7 @@ +build: + name: External functions + steps: + - eas/download_project + - eas/build_project + - eas/build + - eas/submit diff --git a/packages/steps/src/__tests__/fixtures/external-functions.yml b/packages/steps/src/__tests__/fixtures/external-functions.yml new file mode 100644 index 0000000000..3cc3476ff2 --- /dev/null +++ b/packages/steps/src/__tests__/fixtures/external-functions.yml @@ -0,0 +1,5 @@ +build: + name: External functions + steps: + - eas/download_project + - eas/build_project diff --git a/packages/steps/src/__tests__/fixtures/functions-file-1.yml b/packages/steps/src/__tests__/fixtures/functions-file-1.yml new file mode 100644 index 0000000000..7f1e9b9613 --- /dev/null +++ b/packages/steps/src/__tests__/fixtures/functions-file-1.yml @@ -0,0 +1,22 @@ +import: + - functions-file-2.yml + +functions: + say_hi: + name: Hi! + inputs: + - name: name + type: string + allowed_values: [Wojtek, Dominik, Szymon, Brent] + - name: test + type: number + default_value: ${ eas.job.version.buildNumber } + - name: json + type: json + default_value: + property1: value1 + property2: + - value2 + - value3: + property3: value4 + command: echo "Hi, ${ inputs.name }!" diff --git a/packages/steps/src/__tests__/fixtures/functions-file-2.yml b/packages/steps/src/__tests__/fixtures/functions-file-2.yml new file mode 100644 index 0000000000..ecda8eb54e --- /dev/null +++ b/packages/steps/src/__tests__/fixtures/functions-file-2.yml @@ -0,0 +1,7 @@ +import: + - functions-file-3.yml + +functions: + say_hi_wojtek: + name: Hi, Wojtek! + command: echo "Hi, Wojtek!" diff --git a/packages/steps/src/__tests__/fixtures/functions-file-3.yml b/packages/steps/src/__tests__/fixtures/functions-file-3.yml new file mode 100644 index 0000000000..1816489290 --- /dev/null +++ b/packages/steps/src/__tests__/fixtures/functions-file-3.yml @@ -0,0 +1,4 @@ +functions: + say_hi_wojtek: + name: Hi, Wojtek!!! + command: echo "Hi, Wojtek!!!" diff --git a/packages/steps/src/__tests__/fixtures/functions-import-cycle.yml b/packages/steps/src/__tests__/fixtures/functions-import-cycle.yml new file mode 100644 index 0000000000..4e8909b592 --- /dev/null +++ b/packages/steps/src/__tests__/fixtures/functions-import-cycle.yml @@ -0,0 +1,9 @@ +import: + - functions-import-cycle.yml + +functions: + say_hi: + name: Hi! + inputs: + - name + command: echo "Hi, ${ inputs.name }!" diff --git a/packages/steps/src/__tests__/fixtures/functions-with-platforms-property-darwin.yml b/packages/steps/src/__tests__/fixtures/functions-with-platforms-property-darwin.yml new file mode 100644 index 0000000000..14b80d6ae9 --- /dev/null +++ b/packages/steps/src/__tests__/fixtures/functions-with-platforms-property-darwin.yml @@ -0,0 +1,14 @@ +functions: + say_hi_darwin: + name: Hi! + inputs: + - name + command: echo "Hi, ${ inputs.name }!" + supported_platforms: [darwin] + + say_bye_darwin: + name: Bye! + inputs: + - name + command: echo "Bye, ${ inputs.name }!" + supported_platforms: [darwin] diff --git a/packages/steps/src/__tests__/fixtures/functions-with-platforms-property-linux.yml b/packages/steps/src/__tests__/fixtures/functions-with-platforms-property-linux.yml new file mode 100644 index 0000000000..6ab5f3b100 --- /dev/null +++ b/packages/steps/src/__tests__/fixtures/functions-with-platforms-property-linux.yml @@ -0,0 +1,14 @@ +functions: + say_hi_linux: + name: Hi! + inputs: + - name + command: echo "Hi, ${ inputs.name }!" + supported_platforms: [linux] + + say_bye_linux: + name: Bye! + inputs: + - name + command: echo "Bye, ${ inputs.name }!" + supported_platforms: [linux] diff --git a/packages/steps/src/__tests__/fixtures/functions-with-platforms-property.yml b/packages/steps/src/__tests__/fixtures/functions-with-platforms-property.yml new file mode 100644 index 0000000000..d0a3931967 --- /dev/null +++ b/packages/steps/src/__tests__/fixtures/functions-with-platforms-property.yml @@ -0,0 +1,26 @@ +functions: + say_hi_linux_and_darwin: + name: Hi! + inputs: + - name + command: echo "Hi, ${ inputs.name }!" + supported_platforms: [darwin, linux] + + say_bye_linux_and_darwin: + name: Bye! + inputs: + - name: name + type: string + - name: test + type: boolean + default_value: ${ eas.job.platform } + - name: test2 + type: json + default_value: + property1: value1 + property2: + - value2 + - value3: + property3: value4 + command: echo "Bye, ${ inputs.name }!" + supported_platforms: [darwin, linux] diff --git a/packages/steps/src/__tests__/fixtures/functions.yml b/packages/steps/src/__tests__/fixtures/functions.yml new file mode 100644 index 0000000000..4be05657c8 --- /dev/null +++ b/packages/steps/src/__tests__/fixtures/functions.yml @@ -0,0 +1,95 @@ +build: + name: Functions + steps: + - say_hi: + env: + ENV1: value1 + ENV2: value2 + inputs: + name: Dominik + build_number: ${ eas.job.version.buildNumber } + json_input: + property1: value1 + property2: + - aaa + - bbb + - say_hi: + name: Hi, Szymon! + inputs: + name: Szymon + build_number: 122 + - say_hi_wojtek + - random: + id: random_number + - print: + inputs: + value: ${ steps.random_number.value } + - say_hi_2: + inputs: + greeting: Hello + number: 123 + - my_ts_fn: + inputs: + name: Dominik + num: 123 + obj: + property1: value1 + property2: + - aaa + - bbb + +functions: + say_hi: + name: Hi! + inputs: + - name + - name: build_number + type: number + - name: json_input + type: json + default_value: + property1: value1 + property2: + - value2 + - value3: + property3: value4 + command: echo "Hi, ${ inputs.name }!" + say_hi_wojtek: + name: Hi, Wojtek! + command: echo "Hi, Wojtek!" + random: + name: Generate random number + outputs: [value] + command: set-output value 6 + print: + inputs: [value] + command: echo "${ inputs.value }" + say_hi_2: + name: Hi! + supported_platforms: [darwin, linux] + inputs: + - name: greeting + default_value: Hi + allowed_values: [Hi, Hello] + - name: name + default_value: Brent + - name: test + default_value: false + allowed_values: [false, true] + type: boolean + - name: number + type: number + command: echo "${ inputs.greeting }, ${ inputs.name }!" + my_ts_fn: + name: My TS function + inputs: + - name: name + - name: num + type: number + - name: obj + type: json + outputs: + - name: name + - name: num + - name: obj + path: ./my-custom-ts-function diff --git a/packages/steps/src/__tests__/fixtures/inputs.yml b/packages/steps/src/__tests__/fixtures/inputs.yml new file mode 100644 index 0000000000..07623b480b --- /dev/null +++ b/packages/steps/src/__tests__/fixtures/inputs.yml @@ -0,0 +1,17 @@ +build: + name: Inputs + steps: + - run: + name: Say HI + inputs: + name: Dominik Sokal + country: Poland + boolean_value: true + number_value: 123 + json_value: + property1: value1 + property2: + - value2 + - value3: + property3: value4 + command: echo "Hi, ${ inputs.name }, ${ inputs.boolean_value }!" diff --git a/packages/steps/src/__tests__/fixtures/invalid-functions.yml b/packages/steps/src/__tests__/fixtures/invalid-functions.yml new file mode 100644 index 0000000000..e97a390141 --- /dev/null +++ b/packages/steps/src/__tests__/fixtures/invalid-functions.yml @@ -0,0 +1,14 @@ +import: + - functions-file-2.yml + +functions: + say_hi: + name: Hi! + inputs: + - name: name + type: boolean + allowed_values: [Wojtek, Dominik, Szymon, Brent] + - name: test + type: boolean + default_value: ${ wrong.job.platform } + command: echo "Hi, ${ inputs.name }!" diff --git a/packages/steps/src/__tests__/fixtures/invalid.yml b/packages/steps/src/__tests__/fixtures/invalid.yml new file mode 100644 index 0000000000..db41e56306 --- /dev/null +++ b/packages/steps/src/__tests__/fixtures/invalid.yml @@ -0,0 +1,3 @@ +build: + name: Blah + name: Duplicated key diff --git a/packages/steps/src/__tests__/fixtures/my-custom-ts-function/build/index.js b/packages/steps/src/__tests__/fixtures/my-custom-ts-function/build/index.js new file mode 100644 index 0000000000..ead9068a5b --- /dev/null +++ b/packages/steps/src/__tests__/fixtures/my-custom-ts-function/build/index.js @@ -0,0 +1,32 @@ +"use strict"; +var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) { + function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); } + return new (P || (P = Promise))(function (resolve, reject) { + function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } } + function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } } + function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); } + step((generator = generator.apply(thisArg, _arguments || [])).next()); + }); +}; +Object.defineProperty(exports, "__esModule", { value: true }); +function myTsFunction(ctx, { inputs, outputs, env, }) { + return __awaiter(this, void 0, void 0, function* () { + ctx.logger.info('Running my custom TS function'); + ctx.logger.info(`Hello, ${inputs.name.value}!}`); + ctx.logger.info(`Your number is ${inputs.num.value}`); + ctx.logger.info(`Your object is ${JSON.stringify(inputs.obj.value)}`); + ctx.logger.info('Done running my custom TS function'); + ctx.logger.warn('Warning from my custom TS function'); + ctx.logger.error('Error from my custom TS function'); + ctx.logger.info('Running a command'); + ctx.logger.debug('Debugging a command'); + ctx.logger.fatal('Fatal error from my custom TS function'); + ctx.logger.info('Setting outputs'); + outputs.name.set('Brent'); + outputs.num.set('123'); + outputs.obj.set(JSON.stringify({ foo: 'bar' })); // TODO: add support for other types of outputs then string + ctx.logger.info('Setting env vars'); + env['MY_ENV_VAR'] = 'my-value'; + }); +} +exports.default = myTsFunction; diff --git a/packages/steps/src/__tests__/fixtures/my-custom-ts-function/package.json b/packages/steps/src/__tests__/fixtures/my-custom-ts-function/package.json new file mode 100644 index 0000000000..70d9ce3233 --- /dev/null +++ b/packages/steps/src/__tests__/fixtures/my-custom-ts-function/package.json @@ -0,0 +1,20 @@ +{ + "name": "test-custom-ts-function", + "version": "1.0.0", + "description": "", + "main": "./build/index.js", + "type": "commonjs", + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1" + }, + "keywords": [], + "author": "", + "license": "ISC", + "devDependencies": { + "@types/node": "20.14.2", + "typescript": "^5.1.6" + }, + "dependencies": { + "@expo/steps": "^1.0.25" + } +} diff --git a/packages/steps/src/__tests__/fixtures/my-custom-ts-function/src/index.ts b/packages/steps/src/__tests__/fixtures/my-custom-ts-function/src/index.ts new file mode 100644 index 0000000000..acefbcd6e8 --- /dev/null +++ b/packages/steps/src/__tests__/fixtures/my-custom-ts-function/src/index.ts @@ -0,0 +1,53 @@ +import { + BuildStepContext, + BuildStepInput, + BuildStepInputValueTypeName, + BuildStepOutput, + BuildStepEnv, +} from '@expo/steps'; + +interface MyTsFunctionInputs { + name: BuildStepInput; + num: BuildStepInput; + obj: BuildStepInput; +} + +interface MyTsFunctionOutputs { + name: BuildStepOutput; + num: BuildStepOutput; + obj: BuildStepOutput; +} + +async function myTsFunctionAsync( + ctx: BuildStepContext, + { + inputs, + outputs, + env, + }: { + inputs: MyTsFunctionInputs; + outputs: MyTsFunctionOutputs; + env: BuildStepEnv; + } +): Promise { + ctx.logger.info('Running my custom TS function'); + ctx.logger.info(`Hello, ${inputs.name.value}!}`); + ctx.logger.info(`Your number is ${inputs.num.value}`); + ctx.logger.info(`Your object is ${JSON.stringify(inputs.obj.value)}`); + ctx.logger.info('Done running my custom TS function'); + ctx.logger.warn('Warning from my custom TS function'); + ctx.logger.error('Error from my custom TS function'); + ctx.logger.info('Running a command'); + ctx.logger.debug('Debugging a command'); + ctx.logger.fatal('Fatal error from my custom TS function'); + + ctx.logger.info('Setting outputs'); + outputs.name.set('Brent'); + outputs.num.set('123'); + outputs.obj.set(JSON.stringify({ foo: 'bar' })); // TODO: add support for other types of outputs then string + + ctx.logger.info('Setting env vars'); + env['MY_ENV_VAR'] = 'my-value'; +} + +export default myTsFunctionAsync; diff --git a/packages/steps/src/__tests__/fixtures/my-custom-ts-function/tsconfig.json b/packages/steps/src/__tests__/fixtures/my-custom-ts-function/tsconfig.json new file mode 100644 index 0000000000..6256fe52e7 --- /dev/null +++ b/packages/steps/src/__tests__/fixtures/my-custom-ts-function/tsconfig.json @@ -0,0 +1,109 @@ +{ + "compilerOptions": { + /* Visit https://aka.ms/tsconfig to read more about this file */ + + /* Projects */ + // "incremental": true, /* Save .tsbuildinfo files to allow for incremental compilation of projects. */ + // "composite": true, /* Enable constraints that allow a TypeScript project to be used with project references. */ + // "tsBuildInfoFile": "./.tsbuildinfo", /* Specify the path to .tsbuildinfo incremental compilation file. */ + // "disableSourceOfProjectReferenceRedirect": true, /* Disable preferring source files instead of declaration files when referencing composite projects. */ + // "disableSolutionSearching": true, /* Opt a project out of multi-project reference checking when editing. */ + // "disableReferencedProjectLoad": true, /* Reduce the number of projects loaded automatically by TypeScript. */ + + /* Language and Environment */ + "target": "es2016", /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */ + "lib": ["es6"], /* Specify a set of bundled library declaration files that describe the target runtime environment. */ + // "jsx": "preserve", /* Specify what JSX code is generated. */ + // "experimentalDecorators": true, /* Enable experimental support for legacy experimental decorators. */ + // "emitDecoratorMetadata": true, /* Emit design-type metadata for decorated declarations in source files. */ + // "jsxFactory": "", /* Specify the JSX factory function used when targeting React JSX emit, e.g. 'React.createElement' or 'h'. */ + // "jsxFragmentFactory": "", /* Specify the JSX Fragment reference used for fragments when targeting React JSX emit e.g. 'React.Fragment' or 'Fragment'. */ + // "jsxImportSource": "", /* Specify module specifier used to import the JSX factory functions when using 'jsx: react-jsx*'. */ + // "reactNamespace": "", /* Specify the object invoked for 'createElement'. This only applies when targeting 'react' JSX emit. */ + // "noLib": true, /* Disable including any library files, including the default lib.d.ts. */ + // "useDefineForClassFields": true, /* Emit ECMAScript-standard-compliant class fields. */ + // "moduleDetection": "auto", /* Control what method is used to detect module-format JS files. */ + + /* Modules */ + "module": "commonjs", /* Specify what module code is generated. */ + "rootDir": "src", /* Specify the root folder within your source files. */ + // "moduleResolution": "node10", /* Specify how TypeScript looks up a file from a given module specifier. */ + // "baseUrl": "./", /* Specify the base directory to resolve non-relative module names. */ + // "paths": {}, /* Specify a set of entries that re-map imports to additional lookup locations. */ + // "rootDirs": [], /* Allow multiple folders to be treated as one when resolving modules. */ + // "typeRoots": [], /* Specify multiple folders that act like './node_modules/@types'. */ + // "types": [], /* Specify type package names to be included without being referenced in a source file. */ + // "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */ + // "moduleSuffixes": [], /* List of file name suffixes to search when resolving a module. */ + // "allowImportingTsExtensions": true, /* Allow imports to include TypeScript file extensions. Requires '--moduleResolution bundler' and either '--noEmit' or '--emitDeclarationOnly' to be set. */ + // "resolvePackageJsonExports": true, /* Use the package.json 'exports' field when resolving package imports. */ + // "resolvePackageJsonImports": true, /* Use the package.json 'imports' field when resolving imports. */ + // "customConditions": [], /* Conditions to set in addition to the resolver-specific defaults when resolving imports. */ + "resolveJsonModule": true, /* Enable importing .json files. */ + // "allowArbitraryExtensions": true, /* Enable importing files with any extension, provided a declaration file is present. */ + // "noResolve": true, /* Disallow 'import's, 'require's or ''s from expanding the number of files TypeScript should add to a project. */ + + /* JavaScript Support */ + "allowJs": true, /* Allow JavaScript files to be a part of your program. Use the 'checkJS' option to get errors from these files. */ + // "checkJs": true, /* Enable error reporting in type-checked JavaScript files. */ + // "maxNodeModuleJsDepth": 1, /* Specify the maximum folder depth used for checking JavaScript files from 'node_modules'. Only applicable with 'allowJs'. */ + + /* Emit */ + // "declaration": true, /* Generate .d.ts files from TypeScript and JavaScript files in your project. */ + // "declarationMap": true, /* Create sourcemaps for d.ts files. */ + // "emitDeclarationOnly": true, /* Only output d.ts files and not JavaScript files. */ + // "sourceMap": true, /* Create source map files for emitted JavaScript files. */ + // "inlineSourceMap": true, /* Include sourcemap files inside the emitted JavaScript. */ + // "outFile": "./", /* Specify a file that bundles all outputs into one JavaScript file. If 'declaration' is true, also designates a file that bundles all .d.ts output. */ + "outDir": "build", /* Specify an output folder for all emitted files. */ + // "removeComments": true, /* Disable emitting comments. */ + // "noEmit": true, /* Disable emitting files from a compilation. */ + // "importHelpers": true, /* Allow importing helper functions from tslib once per project, instead of including them per-file. */ + // "importsNotUsedAsValues": "remove", /* Specify emit/checking behavior for imports that are only used for types. */ + // "downlevelIteration": true, /* Emit more compliant, but verbose and less performant JavaScript for iteration. */ + // "sourceRoot": "", /* Specify the root path for debuggers to find the reference source code. */ + // "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */ + // "inlineSources": true, /* Include source code in the sourcemaps inside the emitted JavaScript. */ + // "emitBOM": true, /* Emit a UTF-8 Byte Order Mark (BOM) in the beginning of output files. */ + // "newLine": "crlf", /* Set the newline character for emitting files. */ + // "stripInternal": true, /* Disable emitting declarations that have '@internal' in their JSDoc comments. */ + // "noEmitHelpers": true, /* Disable generating custom helper functions like '__extends' in compiled output. */ + // "noEmitOnError": true, /* Disable emitting files if any type checking errors are reported. */ + // "preserveConstEnums": true, /* Disable erasing 'const enum' declarations in generated code. */ + // "declarationDir": "./", /* Specify the output directory for generated declaration files. */ + // "preserveValueImports": true, /* Preserve unused imported values in the JavaScript output that would otherwise be removed. */ + + /* Interop Constraints */ + // "isolatedModules": true, /* Ensure that each file can be safely transpiled without relying on other imports. */ + // "verbatimModuleSyntax": true, /* Do not transform or elide any imports or exports not marked as type-only, ensuring they are written in the output file's format based on the 'module' setting. */ + // "allowSyntheticDefaultImports": true, /* Allow 'import x from y' when a module doesn't have a default export. */ + "esModuleInterop": true, /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables 'allowSyntheticDefaultImports' for type compatibility. */ + // "preserveSymlinks": true, /* Disable resolving symlinks to their realpath. This correlates to the same flag in node. */ + "forceConsistentCasingInFileNames": true, /* Ensure that casing is correct in imports. */ + + /* Type Checking */ + "strict": true, /* Enable all strict type-checking options. */ + "noImplicitAny": true, /* Enable error reporting for expressions and declarations with an implied 'any' type. */ + // "strictNullChecks": true, /* When type checking, take into account 'null' and 'undefined'. */ + // "strictFunctionTypes": true, /* When assigning functions, check to ensure parameters and the return values are subtype-compatible. */ + // "strictBindCallApply": true, /* Check that the arguments for 'bind', 'call', and 'apply' methods match the original function. */ + // "strictPropertyInitialization": true, /* Check for class properties that are declared but not set in the constructor. */ + // "noImplicitThis": true, /* Enable error reporting when 'this' is given the type 'any'. */ + // "useUnknownInCatchVariables": true, /* Default catch clause variables as 'unknown' instead of 'any'. */ + // "alwaysStrict": true, /* Ensure 'use strict' is always emitted. */ + // "noUnusedLocals": true, /* Enable error reporting when local variables aren't read. */ + // "noUnusedParameters": true, /* Raise an error when a function parameter isn't read. */ + // "exactOptionalPropertyTypes": true, /* Interpret optional property types as written, rather than adding 'undefined'. */ + // "noImplicitReturns": true, /* Enable error reporting for codepaths that do not explicitly return in a function. */ + // "noFallthroughCasesInSwitch": true, /* Enable error reporting for fallthrough cases in switch statements. */ + // "noUncheckedIndexedAccess": true, /* Add 'undefined' to a type when accessed using an index. */ + // "noImplicitOverride": true, /* Ensure overriding members in derived classes are marked with an override modifier. */ + // "noPropertyAccessFromIndexSignature": true, /* Enforces using indexed accessors for keys declared using an indexed type. */ + // "allowUnusedLabels": true, /* Disable error reporting for unused labels. */ + // "allowUnreachableCode": true, /* Disable error reporting for unreachable code. */ + + /* Completeness */ + // "skipDefaultLibCheck": true, /* Skip type checking .d.ts files that are included with TypeScript. */ + "skipLibCheck": true /* Skip type checking all .d.ts files. */ + } +} diff --git a/packages/steps/src/__tests__/fixtures/my-custom-ts-function/yarn.lock b/packages/steps/src/__tests__/fixtures/my-custom-ts-function/yarn.lock new file mode 100644 index 0000000000..843b9b64d6 --- /dev/null +++ b/packages/steps/src/__tests__/fixtures/my-custom-ts-function/yarn.lock @@ -0,0 +1,283 @@ +# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. +# yarn lockfile v1 + + +"@expo/logger@1.0.21": + version "1.0.21" + resolved "https://registry.yarnpkg.com/@expo/logger/-/logger-1.0.21.tgz#9271b52b8e27159cbbe3385b9a01fca3317b37c2" + integrity sha512-Vz+LHDGWUfowAwL10nBKqxrMYvO4qdZ8hLKcqUYc5mXBt+jmv7RP+8fcsMIejJEMWmYC2meO5eeCMLKKhEIgMA== + dependencies: + "@types/bunyan" "^1.8.8" + bunyan "^1.8.15" + +"@expo/spawn-async@^1.7.0": + version "1.7.2" + resolved "https://registry.yarnpkg.com/@expo/spawn-async/-/spawn-async-1.7.2.tgz#fcfe66c3e387245e72154b1a7eae8cada6a47f58" + integrity sha512-QdWi16+CHB9JYP7gma19OVVg0BFkvU8zNj9GjWorYI8Iv8FUxjOCcYRuAmX4s/h91e4e7BPsskc8cSrZYho9Ew== + dependencies: + cross-spawn "^7.0.3" + +"@expo/steps@^1.0.25": + version "1.0.25" + resolved "https://registry.yarnpkg.com/@expo/steps/-/steps-1.0.25.tgz#04f8c18bb12f2445cdca443b48497d3c2be3e133" + integrity sha512-MBtJ/3+T5qvXxmnOVlfapKlKQsxPUyKoEUaA6lLbpmm8yCcXJEblnmQP/2ZokAG78IWjsYl5E02Ei/AnrIxB9g== + dependencies: + "@expo/logger" "1.0.21" + "@expo/spawn-async" "^1.7.0" + arg "^5.0.2" + joi "^17.7.0" + lodash.get "^4.4.2" + this-file "^2.0.3" + uuid "^9.0.0" + yaml "^2.2.1" + +"@hapi/hoek@^9.0.0": + version "9.3.0" + resolved "https://registry.yarnpkg.com/@hapi/hoek/-/hoek-9.3.0.tgz#8368869dcb735be2e7f5cb7647de78e167a251fb" + integrity sha512-/c6rf4UJlmHlC9b5BaNvzAcFv7HZ2QHaV0D4/HNlBdvFnvQq8RI4kYdhyPCl7Xj+oWvTWQ8ujhqS53LIgAe6KQ== + +"@hapi/topo@^5.0.0": + version "5.1.0" + resolved "https://registry.yarnpkg.com/@hapi/topo/-/topo-5.1.0.tgz#dc448e332c6c6e37a4dc02fd84ba8d44b9afb012" + integrity sha512-foQZKJig7Ob0BMAYBfcJk8d77QtOe7Wo4ox7ff1lQYoNNAb6jwcY1ncdoy2e9wQZzvNy7ODZCYJkK8kzmcAnAg== + dependencies: + "@hapi/hoek" "^9.0.0" + +"@sideway/address@^4.1.3": + version "4.1.4" + resolved "https://registry.yarnpkg.com/@sideway/address/-/address-4.1.4.tgz#03dccebc6ea47fdc226f7d3d1ad512955d4783f0" + integrity sha512-7vwq+rOHVWjyXxVlR76Agnvhy8I9rpzjosTESvmhNeXOXdZZB15Fl+TI9x1SiHZH5Jv2wTGduSxFDIaq0m3DUw== + dependencies: + "@hapi/hoek" "^9.0.0" + +"@sideway/formula@^3.0.1": + version "3.0.1" + resolved "https://registry.yarnpkg.com/@sideway/formula/-/formula-3.0.1.tgz#80fcbcbaf7ce031e0ef2dd29b1bfc7c3f583611f" + integrity sha512-/poHZJJVjx3L+zVD6g9KgHfYnb443oi7wLu/XKojDviHy6HOEOA6z1Trk5aR1dGcmPenJEgb2sK2I80LeS3MIg== + +"@sideway/pinpoint@^2.0.0": + version "2.0.0" + resolved "https://registry.yarnpkg.com/@sideway/pinpoint/-/pinpoint-2.0.0.tgz#cff8ffadc372ad29fd3f78277aeb29e632cc70df" + integrity sha512-RNiOoTPkptFtSVzQevY/yWtZwf/RxyVnPy/OcA9HBM3MlGDnBEYL5B41H0MTn0Uec8Hi+2qUtTfG2WWZBmMejQ== + +"@types/bunyan@^1.8.8": + version "1.8.8" + resolved "https://registry.yarnpkg.com/@types/bunyan/-/bunyan-1.8.8.tgz#8d6d33f090f37c07e2a80af30ae728450a101008" + integrity sha512-Cblq+Yydg3u+sGiz2mjHjC5MPmdjY+No4qvHrF+BUhblsmSfMvsHLbOG62tPbonsqBj6sbWv1LHcsoe5Jw+/Ow== + dependencies: + "@types/node" "*" + +"@types/node@*", "@types/node@^20.4.1": + version "20.4.1" + resolved "https://registry.yarnpkg.com/@types/node/-/node-20.4.1.tgz#a6033a8718653c50ac4962977e14d0f984d9527d" + integrity sha512-JIzsAvJeA/5iY6Y/OxZbv1lUcc8dNSE77lb2gnBH+/PJ3lFR1Ccvgwl5JWnHAkNHcRsT0TbpVOsiMKZ1F/yyJg== + +arg@^5.0.2: + version "5.0.2" + resolved "https://registry.yarnpkg.com/arg/-/arg-5.0.2.tgz#c81433cc427c92c4dcf4865142dbca6f15acd59c" + integrity sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg== + +balanced-match@^1.0.0: + version "1.0.2" + resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-1.0.2.tgz#e83e3a7e3f300b34cb9d87f615fa0cbf357690ee" + integrity sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw== + +brace-expansion@^1.1.7: + version "1.1.11" + resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-1.1.11.tgz#3c7fcbf529d87226f3d2f52b966ff5271eb441dd" + integrity sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA== + dependencies: + balanced-match "^1.0.0" + concat-map "0.0.1" + +bunyan@^1.8.15: + version "1.8.15" + resolved "https://registry.yarnpkg.com/bunyan/-/bunyan-1.8.15.tgz#8ce34ca908a17d0776576ca1b2f6cbd916e93b46" + integrity sha512-0tECWShh6wUysgucJcBAoYegf3JJoZWibxdqhTm7OHPeT42qdjkZ29QCMcKwbgU1kiH+auSIasNRXMLWXafXig== + optionalDependencies: + dtrace-provider "~0.8" + moment "^2.19.3" + mv "~2" + safe-json-stringify "~1" + +concat-map@0.0.1: + version "0.0.1" + resolved "https://registry.yarnpkg.com/concat-map/-/concat-map-0.0.1.tgz#d8a96bd77fd68df7793a73036a3ba0d5405d477b" + integrity sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg== + +cross-spawn@^7.0.3: + version "7.0.3" + resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-7.0.3.tgz#f73a85b9d5d41d045551c177e2882d4ac85728a6" + integrity sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w== + dependencies: + path-key "^3.1.0" + shebang-command "^2.0.0" + which "^2.0.1" + +dtrace-provider@~0.8: + version "0.8.8" + resolved "https://registry.yarnpkg.com/dtrace-provider/-/dtrace-provider-0.8.8.tgz#2996d5490c37e1347be263b423ed7b297fb0d97e" + integrity sha512-b7Z7cNtHPhH9EJhNNbbeqTcXB8LGFFZhq1PGgEvpeHlzd36bhbdTWoE/Ba/YguqpBSlAPKnARWhVlhunCMwfxg== + dependencies: + nan "^2.14.0" + +glob@^6.0.1: + version "6.0.4" + resolved "https://registry.yarnpkg.com/glob/-/glob-6.0.4.tgz#0f08860f6a155127b2fadd4f9ce24b1aab6e4d22" + integrity sha512-MKZeRNyYZAVVVG1oZeLaWie1uweH40m9AZwIwxyPbTSX4hHrVYSzLg0Ro5Z5R7XKkIX+Cc6oD1rqeDJnwsB8/A== + dependencies: + inflight "^1.0.4" + inherits "2" + minimatch "2 || 3" + once "^1.3.0" + path-is-absolute "^1.0.0" + +inflight@^1.0.4: + version "1.0.6" + resolved "https://registry.yarnpkg.com/inflight/-/inflight-1.0.6.tgz#49bd6331d7d02d0c09bc910a1075ba8165b56df9" + integrity sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA== + dependencies: + once "^1.3.0" + wrappy "1" + +inherits@2: + version "2.0.4" + resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.4.tgz#0fa2c64f932917c3433a0ded55363aae37416b7c" + integrity sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ== + +isexe@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/isexe/-/isexe-2.0.0.tgz#e8fbf374dc556ff8947a10dcb0572d633f2cfa10" + integrity sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw== + +joi@^17.7.0: + version "17.9.2" + resolved "https://registry.yarnpkg.com/joi/-/joi-17.9.2.tgz#8b2e4724188369f55451aebd1d0b1d9482470690" + integrity sha512-Itk/r+V4Dx0V3c7RLFdRh12IOjySm2/WGPMubBT92cQvRfYZhPM2W0hZlctjj72iES8jsRCwp7S/cRmWBnJ4nw== + dependencies: + "@hapi/hoek" "^9.0.0" + "@hapi/topo" "^5.0.0" + "@sideway/address" "^4.1.3" + "@sideway/formula" "^3.0.1" + "@sideway/pinpoint" "^2.0.0" + +lodash.get@^4.4.2: + version "4.4.2" + resolved "https://registry.yarnpkg.com/lodash.get/-/lodash.get-4.4.2.tgz#2d177f652fa31e939b4438d5341499dfa3825e99" + integrity sha512-z+Uw/vLuy6gQe8cfaFWD7p0wVv8fJl3mbzXh33RS+0oW2wvUqiRXiQ69gLWSLpgB5/6sU+r6BlQR0MBILadqTQ== + +"minimatch@2 || 3": + version "3.1.2" + resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-3.1.2.tgz#19cd194bfd3e428f049a70817c038d89ab4be35b" + integrity sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw== + dependencies: + brace-expansion "^1.1.7" + +minimist@^1.2.6: + version "1.2.8" + resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.8.tgz#c1a464e7693302e082a075cee0c057741ac4772c" + integrity sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA== + +mkdirp@~0.5.1: + version "0.5.6" + resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-0.5.6.tgz#7def03d2432dcae4ba1d611445c48396062255f6" + integrity sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw== + dependencies: + minimist "^1.2.6" + +moment@^2.19.3: + version "2.29.4" + resolved "https://registry.yarnpkg.com/moment/-/moment-2.29.4.tgz#3dbe052889fe7c1b2ed966fcb3a77328964ef108" + integrity sha512-5LC9SOxjSc2HF6vO2CyuTDNivEdoz2IvyJJGj6X8DJ0eFyfszE0QiEd+iXmBvUP3WHxSjFH/vIsA0EN00cgr8w== + +mv@~2: + version "2.1.1" + resolved "https://registry.yarnpkg.com/mv/-/mv-2.1.1.tgz#ae6ce0d6f6d5e0a4f7d893798d03c1ea9559b6a2" + integrity sha512-at/ZndSy3xEGJ8i0ygALh8ru9qy7gWW1cmkaqBN29JmMlIvM//MEO9y1sk/avxuwnPcfhkejkLsuPxH81BrkSg== + dependencies: + mkdirp "~0.5.1" + ncp "~2.0.0" + rimraf "~2.4.0" + +nan@^2.14.0: + version "2.17.0" + resolved "https://registry.yarnpkg.com/nan/-/nan-2.17.0.tgz#c0150a2368a182f033e9aa5195ec76ea41a199cb" + integrity sha512-2ZTgtl0nJsO0KQCjEpxcIr5D+Yv90plTitZt9JBfQvVJDS5seMl3FOvsh3+9CoYWXf/1l5OaZzzF6nDm4cagaQ== + +ncp@~2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/ncp/-/ncp-2.0.0.tgz#195a21d6c46e361d2fb1281ba38b91e9df7bdbb3" + integrity sha512-zIdGUrPRFTUELUvr3Gmc7KZ2Sw/h1PiVM0Af/oHB6zgnV1ikqSfRk+TOufi79aHYCW3NiOXmr1BP5nWbzojLaA== + +once@^1.3.0: + version "1.4.0" + resolved "https://registry.yarnpkg.com/once/-/once-1.4.0.tgz#583b1aa775961d4b113ac17d9c50baef9dd76bd1" + integrity sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w== + dependencies: + wrappy "1" + +path-is-absolute@^1.0.0: + version "1.0.1" + resolved "https://registry.yarnpkg.com/path-is-absolute/-/path-is-absolute-1.0.1.tgz#174b9268735534ffbc7ace6bf53a5a9e1b5c5f5f" + integrity sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg== + +path-key@^3.1.0: + version "3.1.1" + resolved "https://registry.yarnpkg.com/path-key/-/path-key-3.1.1.tgz#581f6ade658cbba65a0d3380de7753295054f375" + integrity sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q== + +rimraf@~2.4.0: + version "2.4.5" + resolved "https://registry.yarnpkg.com/rimraf/-/rimraf-2.4.5.tgz#ee710ce5d93a8fdb856fb5ea8ff0e2d75934b2da" + integrity sha512-J5xnxTyqaiw06JjMftq7L9ouA448dw/E7dKghkP9WpKNuwmARNNg+Gk8/u5ryb9N/Yo2+z3MCwuqFK/+qPOPfQ== + dependencies: + glob "^6.0.1" + +safe-json-stringify@~1: + version "1.2.0" + resolved "https://registry.yarnpkg.com/safe-json-stringify/-/safe-json-stringify-1.2.0.tgz#356e44bc98f1f93ce45df14bcd7c01cda86e0afd" + integrity sha512-gH8eh2nZudPQO6TytOvbxnuhYBOvDBBLW52tz5q6X58lJcd/tkmqFR+5Z9adS8aJtURSXWThWy/xJtJwixErvg== + +shebang-command@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/shebang-command/-/shebang-command-2.0.0.tgz#ccd0af4f8835fbdc265b82461aaf0c36663f34ea" + integrity sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA== + dependencies: + shebang-regex "^3.0.0" + +shebang-regex@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/shebang-regex/-/shebang-regex-3.0.0.tgz#ae16f1644d873ecad843b0307b143362d4c42172" + integrity sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A== + +this-file@^2.0.3: + version "2.0.3" + resolved "https://registry.yarnpkg.com/this-file/-/this-file-2.0.3.tgz#13bd2bcfbab2ce86a37a15689df1d14861032b1b" + integrity sha512-IdMH1bUkVJdJjM7o8v83Mv4QvVPdkAofur20STl2Bbw9uMuuS/bT/PZURkEdZsy9XC/1ZXWgZ1wIL9nvouGaEg== + +typescript@^5.1.6: + version "5.1.6" + resolved "https://registry.yarnpkg.com/typescript/-/typescript-5.1.6.tgz#02f8ac202b6dad2c0dd5e0913745b47a37998274" + integrity sha512-zaWCozRZ6DLEWAWFrVDz1H6FVXzUSfTy5FUMWsQlU8Ym5JP9eO4xkTIROFCQvhQf61z6O/G6ugw3SgAnvvm+HA== + +uuid@^9.0.0: + version "9.0.0" + resolved "https://registry.yarnpkg.com/uuid/-/uuid-9.0.0.tgz#592f550650024a38ceb0c562f2f6aa435761efb5" + integrity sha512-MXcSTerfPa4uqyzStbRoTgt5XIe3x5+42+q1sDuy3R5MDk66URdLMOZe5aPX/SQd+kuYAh0FdP/pO28IkQyTeg== + +which@^2.0.1: + version "2.0.2" + resolved "https://registry.yarnpkg.com/which/-/which-2.0.2.tgz#7c6a8dd0a636a0327e10b59c9286eee93f3f51b1" + integrity sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA== + dependencies: + isexe "^2.0.0" + +wrappy@1: + version "1.0.2" + resolved "https://registry.yarnpkg.com/wrappy/-/wrappy-1.0.2.tgz#b5243d8f3ec1aa35f1364605bc0d1036e30ab69f" + integrity sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ== + +yaml@^2.2.1: + version "2.3.1" + resolved "https://registry.yarnpkg.com/yaml/-/yaml-2.3.1.tgz#02fe0975d23cd441242aa7204e09fc28ac2ac33b" + integrity sha512-2eHWfjaoXgTBC2jNM1LRef62VQa0umtvRiDSk6HSzW7RvS5YtkabJrwYLLEKWBc8a5U2PTSCs+dJjUTJdlHsWQ== diff --git a/packages/steps/src/__tests__/fixtures/outputs.yml b/packages/steps/src/__tests__/fixtures/outputs.yml new file mode 100644 index 0000000000..e751271cc6 --- /dev/null +++ b/packages/steps/src/__tests__/fixtures/outputs.yml @@ -0,0 +1,20 @@ +build: + name: Outputs + steps: + - run: + outputs: [first_name, last_name] + command: | + set-output first_name Brent + set-output last_name Vatne + - run: + outputs: + - name: first_name + required: true + - name: middle_name + required: false + - name: last_name + - nickname + command: | + set-output first_name Dominik + set-output last_name Sokal + set-output nickname dsokal diff --git a/packages/steps/src/__tests__/fixtures/publish-update-job-as-config.yml b/packages/steps/src/__tests__/fixtures/publish-update-job-as-config.yml new file mode 100644 index 0000000000..a714492d41 --- /dev/null +++ b/packages/steps/src/__tests__/fixtures/publish-update-job-as-config.yml @@ -0,0 +1,13 @@ +build: + name: EAS Update – Publish + steps: + - eas/checkout + + - eas/use_npm_token + + - eas/install_node_modules + + - run: + name: Publish update + command: | + EXPO_TOKEN="${ eas.job.secrets.robotAccessToken }" npx -y eas-cli@latest update --auto diff --git a/packages/steps/src/__tests__/utils/context.ts b/packages/steps/src/__tests__/utils/context.ts new file mode 100644 index 0000000000..49463d69f5 --- /dev/null +++ b/packages/steps/src/__tests__/utils/context.ts @@ -0,0 +1,103 @@ +import os from 'os'; +import path from 'path'; + +import { JobInterpolationContext, StaticJobInterpolationContext } from '@expo/eas-build-job'; +import { bunyan } from '@expo/logger'; +import { v4 as uuidv4 } from 'uuid'; + +import { + ExternalBuildContextProvider, + BuildStepGlobalContext, + BuildStepContext, +} from '../../BuildStepContext.js'; +import { BuildRuntimePlatform } from '../../BuildRuntimePlatform.js'; +import { BuildStepEnv } from '../../BuildStepEnv.js'; + +import { createMockLogger } from './logger.js'; + +export class MockContextProvider implements ExternalBuildContextProvider { + private _env: BuildStepEnv = {}; + + constructor( + public readonly logger: bunyan, + public readonly runtimePlatform: BuildRuntimePlatform, + public readonly projectSourceDirectory: string, + public readonly projectTargetDirectory: string, + public readonly defaultWorkingDirectory: string, + public readonly buildLogsDirectory: string, + public readonly staticContextContent: Omit + ) {} + public get env(): BuildStepEnv { + return this._env; + } + public staticContext(): Omit { + return { ...this.staticContextContent }; + } + public updateEnv(env: BuildStepEnv): void { + this._env = env; + } +} + +interface BuildContextParams { + buildId?: string; + logger?: bunyan; + skipCleanup?: boolean; + runtimePlatform?: BuildRuntimePlatform; + projectSourceDirectory?: string; + projectTargetDirectory?: string; + relativeWorkingDirectory?: string; + staticContextContent?: JobInterpolationContext; +} + +export function createStepContextMock({ + buildId, + logger, + skipCleanup, + runtimePlatform, + projectSourceDirectory, + projectTargetDirectory, + relativeWorkingDirectory, + staticContextContent, +}: BuildContextParams = {}): BuildStepContext { + const globalCtx = createGlobalContextMock({ + buildId, + logger, + skipCleanup, + runtimePlatform, + projectSourceDirectory, + projectTargetDirectory, + relativeWorkingDirectory, + staticContextContent, + }); + return new BuildStepContext(globalCtx, { + logger: logger ?? createMockLogger(), + relativeWorkingDirectory, + }); +} + +export function createGlobalContextMock({ + logger, + skipCleanup, + runtimePlatform, + projectSourceDirectory, + projectTargetDirectory, + relativeWorkingDirectory, + staticContextContent, +}: BuildContextParams = {}): BuildStepGlobalContext { + const resolvedProjectTargetDirectory = + projectTargetDirectory ?? path.join(os.tmpdir(), 'eas-build', uuidv4()); + return new BuildStepGlobalContext( + new MockContextProvider( + logger ?? createMockLogger(), + runtimePlatform ?? BuildRuntimePlatform.LINUX, + projectSourceDirectory ?? '/non/existent/dir', + resolvedProjectTargetDirectory, + relativeWorkingDirectory + ? path.resolve(resolvedProjectTargetDirectory, relativeWorkingDirectory) + : resolvedProjectTargetDirectory, + '/non/existent/dir', + staticContextContent ?? ({} as JobInterpolationContext) + ), + skipCleanup ?? false + ); +} diff --git a/packages/steps/src/__tests__/utils/error.ts b/packages/steps/src/__tests__/utils/error.ts new file mode 100644 index 0000000000..7c68599867 --- /dev/null +++ b/packages/steps/src/__tests__/utils/error.ts @@ -0,0 +1,21 @@ +export class NoErrorThrownError extends Error {} + +export const getErrorAsync = async ( + call: () => unknown +): Promise => { + try { + await call(); + throw new NoErrorThrownError(); + } catch (error: unknown) { + return error as TError; + } +}; + +export const getError = (call: () => unknown): TError | NoErrorThrownError => { + try { + call(); + throw new NoErrorThrownError(); + } catch (error: unknown) { + return error as TError; + } +}; diff --git a/packages/steps/src/__tests__/utils/logger.ts b/packages/steps/src/__tests__/utils/logger.ts new file mode 100644 index 0000000000..f011f4e0b5 --- /dev/null +++ b/packages/steps/src/__tests__/utils/logger.ts @@ -0,0 +1,13 @@ +import { jest } from '@jest/globals'; +import { bunyan } from '@expo/logger'; + +export function createMockLogger(): bunyan { + const logger = { + info: jest.fn(), + debug: jest.fn(), + error: jest.fn(), + warn: jest.fn(), + child: jest.fn().mockImplementation(() => createMockLogger()), + } as unknown as bunyan; + return logger; +} diff --git a/packages/steps/src/__tests__/utils/uuid.ts b/packages/steps/src/__tests__/utils/uuid.ts new file mode 100644 index 0000000000..ddeea79b0f --- /dev/null +++ b/packages/steps/src/__tests__/utils/uuid.ts @@ -0,0 +1,2 @@ +export const UUID_REGEX = + /^[0-9a-fA-F]{8}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{12}$/; diff --git a/packages/steps/src/cli/cli.ts b/packages/steps/src/cli/cli.ts new file mode 100644 index 0000000000..09f24a2bd3 --- /dev/null +++ b/packages/steps/src/cli/cli.ts @@ -0,0 +1,87 @@ +import path from 'path'; + +import { Job, Metadata, StaticJobInterpolationContext } from '@expo/eas-build-job'; +import { bunyan, createLogger } from '@expo/logger'; + +import { BuildConfigParser } from '../BuildConfigParser.js'; +import { ExternalBuildContextProvider, BuildStepGlobalContext } from '../BuildStepContext.js'; +import { BuildWorkflowError } from '../errors.js'; +import { BuildRuntimePlatform } from '../BuildRuntimePlatform.js'; +import { BuildStepEnv } from '../BuildStepEnv.js'; + +const logger = createLogger({ + name: 'steps-cli', + level: 'info', +}); + +export class CliContextProvider implements ExternalBuildContextProvider { + private _env: BuildStepEnv = {}; + + constructor( + public readonly logger: bunyan, + public readonly runtimePlatform: BuildRuntimePlatform, + public readonly projectSourceDirectory: string, + public readonly projectTargetDirectory: string, + public readonly defaultWorkingDirectory: string, + public readonly buildLogsDirectory: string + ) {} + public get env(): BuildStepEnv { + return this._env; + } + public staticContext(): Omit { + return { + job: {} as Job, + metadata: {} as Metadata, + expoApiServerURL: 'http://api.expo.test', + }; + } + public updateEnv(env: BuildStepEnv): void { + this._env = env; + } +} + +async function runAsync( + configPath: string, + relativeProjectDirectory: string, + runtimePlatform: BuildRuntimePlatform +): Promise { + const ctx = new BuildStepGlobalContext( + new CliContextProvider( + logger, + runtimePlatform, + relativeProjectDirectory, + relativeProjectDirectory, + relativeProjectDirectory, + relativeProjectDirectory + ), + false + ); + const parser = new BuildConfigParser(ctx, { + configPath, + }); + const workflow = await parser.parseAsync(); + await workflow.executeAsync(); +} + +const relativeConfigPath = process.argv[2]; +const relativeProjectDirectoryPath = process.argv[3]; +const platform: BuildRuntimePlatform = (process.argv[4] ?? + process.platform) as BuildRuntimePlatform; + +if (!relativeConfigPath || !relativeProjectDirectoryPath) { + console.error('Usage: yarn cli config.yml path/to/project/directory [darwin|linux]'); + process.exit(1); +} + +const configPath = path.resolve(process.cwd(), relativeConfigPath); +const workingDirectory = path.resolve(process.cwd(), relativeProjectDirectoryPath); + +runAsync(configPath, workingDirectory, platform).catch((err) => { + logger.error({ err }, 'Build failed'); + if (err instanceof BuildWorkflowError) { + logger.error('Failed to parse the custom build config file.'); + for (const detailedErr of err.errors) { + logger.error({ err: detailedErr }); + } + } +}); diff --git a/packages/steps/src/errors.ts b/packages/steps/src/errors.ts new file mode 100644 index 0000000000..d8bd4d2087 --- /dev/null +++ b/packages/steps/src/errors.ts @@ -0,0 +1,37 @@ +abstract class UserError extends Error { + public readonly cause?: Error; + public readonly metadata: object; + + constructor( + public override readonly message: string, + extra?: { + metadata?: object; + cause?: Error; + } + ) { + super(message); + this.metadata = extra?.cause ?? {}; + this.cause = extra?.cause; + } +} + +export class BuildConfigError extends UserError {} + +export { YAMLParseError as BuildConfigYAMLError } from 'yaml'; + +export class BuildInternalError extends Error {} + +export class BuildStepRuntimeError extends UserError {} + +export class BuildWorkflowError extends UserError { + constructor( + public override readonly message: string, + public readonly errors: BuildConfigError[], + extra?: { + metadata?: object; + cause?: Error; + } + ) { + super(message, extra); + } +} diff --git a/packages/steps/src/index.ts b/packages/steps/src/index.ts new file mode 100644 index 0000000000..d698e0e02b --- /dev/null +++ b/packages/steps/src/index.ts @@ -0,0 +1,18 @@ +export { BuildStepContext } from './BuildStepContext.js'; +export { readAndValidateBuildConfigFromPathAsync } from './BuildConfig.js'; +export { BuildConfigParser } from './BuildConfigParser.js'; +export { StepsConfigParser } from './StepsConfigParser.js'; +export { BuildFunction } from './BuildFunction.js'; +export { BuildRuntimePlatform } from './BuildRuntimePlatform.js'; +export { BuildStepInput, BuildStepInputValueTypeName } from './BuildStepInput.js'; +export { BuildStepOutput } from './BuildStepOutput.js'; +export { BuildStepGlobalContext, ExternalBuildContextProvider } from './BuildStepContext.js'; +export { BuildWorkflow } from './BuildWorkflow.js'; +export { BuildStepEnv } from './BuildStepEnv.js'; +export { BuildFunctionGroup } from './BuildFunctionGroup.js'; +export { BuildStep } from './BuildStep.js'; +export * as errors from './errors.js'; +export * from './interpolation.js'; +export * from './utils/shell/spawn.js'; +export * from './utils/jsepEval.js'; +export * from './utils/hashFiles.js'; diff --git a/packages/steps/src/interpolation.ts b/packages/steps/src/interpolation.ts new file mode 100644 index 0000000000..ef37948939 --- /dev/null +++ b/packages/steps/src/interpolation.ts @@ -0,0 +1,35 @@ +import { JobInterpolationContext } from '@expo/eas-build-job'; + +import { jsepEval } from './utils/jsepEval.js'; + +export function interpolateJobContext({ + target, + context, +}: { + target: unknown; + context: JobInterpolationContext; +}): unknown { + if (typeof target === 'string') { + // If the value is e.g. `build: ${{ inputs.build }}`, we will interpolate the value + // without changing `inputs.build` type, i.e. if it is an object it'll be like `build: {...inputs.build}`. + if (target.startsWith('${{') && target.endsWith('}}')) { + return jsepEval(target.slice(3, -2), context); + } + + // Otherwise we replace all occurrences of `${{...}}` with the result of the expression. + // e.g. `echo ${{ build.profile }}` becomes `echo production`. + return target.replace(/\$\{\{(.+?)\}\}/g, (_match, expression) => { + return `${jsepEval(expression, context)}`; + }); + } else if (Array.isArray(target)) { + return target.map((value) => interpolateJobContext({ target: value, context })); + } else if (typeof target === 'object' && target) { + return Object.fromEntries( + Object.entries(target).map(([key, value]) => [ + key, + interpolateJobContext({ target: value, context }), + ]) + ); + } + return target; +} diff --git a/packages/steps/src/scripts/__tests__/runCustomFunction-test.ts b/packages/steps/src/scripts/__tests__/runCustomFunction-test.ts new file mode 100644 index 0000000000..75f3ecff87 --- /dev/null +++ b/packages/steps/src/scripts/__tests__/runCustomFunction-test.ts @@ -0,0 +1,115 @@ +import path from 'path'; +import os from 'os'; +import fs from 'fs/promises'; + +import { createContext } from 'this-file'; +import { v4 as uuidv4 } from 'uuid'; +import { jest } from '@jest/globals'; + +import { BuildStepInput, BuildStepInputValueTypeName } from '../../BuildStepInput.js'; +import { BuildStepOutput } from '../../BuildStepOutput.js'; +import { createStepContextMock } from '../../__tests__/utils/context.js'; +import { + cleanUpStepTemporaryDirectoriesAsync, + getTemporaryEnvsDirPath, + getTemporaryOutputsDirPath, +} from '../../BuildTemporaryFiles.js'; +import { BIN_PATH } from '../../utils/shell/bin.js'; +import { createCustomFunctionCall } from '../../utils/customFunction.js'; +import { createMockLogger } from '../../__tests__/utils/logger.js'; + +describe('runCustomFunction', () => { + test('can run custom function', async () => { + const dirname = createContext().dirname; + const projectSourceDirectory = path.join(os.tmpdir(), 'eas-build', uuidv4()); + await fs.mkdir(projectSourceDirectory, { recursive: true }); + const logger = createMockLogger(); + // return the same logger instance so we can expect calls on it later + jest.spyOn(logger, 'child').mockImplementation(() => logger); + const ctx = createStepContextMock({ + projectTargetDirectory: path.resolve(dirname, '../../__tests__/fixtures'), + projectSourceDirectory, + logger, + }); + const outputs = { + name: new BuildStepOutput(ctx.global, { + id: 'name', + stepDisplayName: 'test', + required: true, + }), + num: new BuildStepOutput(ctx.global, { + id: 'num', + stepDisplayName: 'test', + required: true, + }), + obj: new BuildStepOutput(ctx.global, { + id: 'obj', + stepDisplayName: 'test', + required: true, + }), + }; + const inputs = { + name: new BuildStepInput(ctx.global, { + id: 'name', + stepDisplayName: 'test', + required: true, + allowedValueTypeName: BuildStepInputValueTypeName.STRING, + }), + num: new BuildStepInput(ctx.global, { + id: 'num', + stepDisplayName: 'test', + required: true, + allowedValueTypeName: BuildStepInputValueTypeName.NUMBER, + }), + obj: new BuildStepInput(ctx.global, { + id: 'obj', + stepDisplayName: 'test', + required: true, + allowedValueTypeName: BuildStepInputValueTypeName.JSON, + }), + }; + inputs.name.set('foo'); + inputs.num.set(123); + inputs.obj.set({ foo: 'bar' }); + + try { + const outputsDir = getTemporaryOutputsDirPath(ctx.global, 'test'); + const envsDir = getTemporaryEnvsDirPath(ctx.global, 'test'); + + await fs.mkdir(outputsDir, { recursive: true }); + await fs.mkdir(envsDir, { recursive: true }); + + const currentPath = process.env.PATH; + const newPath = currentPath ? `${BIN_PATH}:${currentPath}` : BIN_PATH; + const fn = createCustomFunctionCall( + path.resolve(dirname, '../../__tests__/fixtures/my-custom-ts-function') + ); + const promise = fn(ctx, { + env: { + __EXPO_STEPS_OUTPUTS_DIR: outputsDir, + __EXPO_STEPS_ENVS_DIR: envsDir, + __EXPO_STEPS_WORKING_DIRECTORY: ctx.workingDirectory, + PATH: newPath, + }, + inputs: Object.fromEntries( + Object.entries(inputs).map(([id, input]) => [ + id, + { + value: input.getValue({ + interpolationContext: ctx.global.getInterpolationContext(), + }), + }, + ]) + ), + outputs, + }); + await expect(promise).resolves.not.toThrow(); + } finally { + await cleanUpStepTemporaryDirectoriesAsync(ctx.global, 'test'); + } + + expect(jest.mocked(logger.info)).toHaveBeenCalledWith('Hello, foo!}'); // that "}" is in fixture + expect(jest.mocked(logger.info)).toHaveBeenCalledWith('Your number is 123'); + expect(jest.mocked(logger.info)).toHaveBeenCalledWith('Your object is {"foo":"bar"}'); + }); +}); diff --git a/packages/steps/src/scripts/runCustomFunction.ts b/packages/steps/src/scripts/runCustomFunction.ts new file mode 100644 index 0000000000..078cf82128 --- /dev/null +++ b/packages/steps/src/scripts/runCustomFunction.ts @@ -0,0 +1,120 @@ +import assert from 'assert'; + +import { createLogger } from '@expo/logger'; +import { SpawnPromise, SpawnResult } from '@expo/spawn-async'; +import cloneDeep from 'lodash.clonedeep'; + +import { BuildStepOutput } from '../BuildStepOutput.js'; +import { + SerializedCustomBuildFunctionArguments, + deserializeInputs, +} from '../utils/customFunction.js'; +import { BuildStepContext } from '../BuildStepContext.js'; +import { BuildStepFunction } from '../BuildStep.js'; +import { spawnAsync } from '../utils/shell/spawn.js'; + +async function runCustomJsFunctionAsync(): Promise { + const customJavascriptFunctionModulePath = process.argv[2]; + const functionArgs = process.argv[3]; + + assert(customJavascriptFunctionModulePath, 'customJavascriptFunctionModulePath is required'); + assert(functionArgs, 'serializedFunctionParams is required'); + + let serializedFunctionArguments: SerializedCustomBuildFunctionArguments; + try { + serializedFunctionArguments = JSON.parse(functionArgs); + } catch (e) { + console.error('Failed to parse serializedFunctionParams'); + throw e; + } + + const logger = createLogger({ + name: 'customFunctionLogger', + streams: [ + { + type: 'raw', + stream: { + write: (rec: any) => { + if (rec) { + switch (rec.level) { + case 20: // Debug level + if (rec.msg) { + console.debug(rec.msg); + } + break; + case 30: // Info level + if (rec.msg) { + console.log(rec.msg); + } + break; + case 40: // Warn level + if (rec.msg) { + console.warn(rec.msg); + } + break; + case 50: // Error level + case 60: // Fatal level + if (rec.msg) { + console.error(rec.msg); + } + break; + default: + break; + } + } + }, + }, + }, + ], + }); + + const ctx = BuildStepContext.deserialize(serializedFunctionArguments.ctx, logger); + const inputs = deserializeInputs(serializedFunctionArguments.inputs); + const outputs = Object.fromEntries( + Object.entries(serializedFunctionArguments.outputs).map(([id, output]) => [ + id, + BuildStepOutput.deserialize(output), + ]) + ); + const env = serializedFunctionArguments.env; + const envBefore = cloneDeep(serializedFunctionArguments.env); + + let customModule: { default: BuildStepFunction }; + try { + customModule = await require(customJavascriptFunctionModulePath); + } catch (e) { + console.error('Failed to load custom function module'); + throw e; + } + + const customJavascriptFunction = customModule.default; + + await customJavascriptFunction(ctx, { inputs, outputs, env }); + + const promises: SpawnPromise[] = []; + for (const output of Object.values(outputs)) { + if (output.rawValue) { + assert(output.value, 'output.value is required'); + promises.push( + spawnAsync('set-output', [output.id, output.value], { + env, + stdio: 'pipe', + }) + ); + } + } + for (const envName of Object.keys(env)) { + const envValue = env[envName]; + if (envValue !== envBefore[envName] && envValue) { + promises.push( + spawnAsync('set-env', [envName, envValue], { + env, + stdio: 'pipe', + }) + ); + } + } + await Promise.all(promises); +} + +void runCustomJsFunctionAsync(); diff --git a/packages/steps/src/utils/__tests__/customFunction-test.ts b/packages/steps/src/utils/__tests__/customFunction-test.ts new file mode 100644 index 0000000000..c26b5967f0 --- /dev/null +++ b/packages/steps/src/utils/__tests__/customFunction-test.ts @@ -0,0 +1,44 @@ +import { deserializeInputs, serializeInputs } from '../customFunction.js'; + +describe(serializeInputs, () => { + test('serializes inputs correctly', () => { + const inputs = { + foo: { value: 'bar' }, + baz: { value: 123 }, + qux: { value: true }, + quux: { value: { foo: 'bar' } }, + quuux: { value: ['foo', 'bar'] }, + quuuux: { value: null }, + }; + const serializedInputs = serializeInputs(inputs); + expect(serializedInputs).toEqual({ + foo: { serializedValue: '"bar"' }, + baz: { serializedValue: '123' }, + qux: { serializedValue: 'true' }, + quux: { serializedValue: '{"foo":"bar"}' }, + quuux: { serializedValue: '["foo","bar"]' }, + quuuux: { serializedValue: 'null' }, + }); + }); +}); + +describe(deserializeInputs, () => { + test('deserializes inputs correctly', () => { + const inputs = deserializeInputs({ + foo: { serializedValue: '"bar"' }, + baz: { serializedValue: '123' }, + qux: { serializedValue: 'true' }, + quux: { serializedValue: '{"foo":"bar"}' }, + quuux: { serializedValue: '["foo","bar"]' }, + quuuux: { serializedValue: 'null' }, + }); + expect(inputs).toEqual({ + foo: { value: 'bar' }, + baz: { value: 123 }, + qux: { value: true }, + quux: { value: { foo: 'bar' } }, + quuux: { value: ['foo', 'bar'] }, + quuuux: { value: null }, + }); + }); +}); diff --git a/packages/steps/src/utils/__tests__/jsepEval-test.ts b/packages/steps/src/utils/__tests__/jsepEval-test.ts new file mode 100644 index 0000000000..74ee61cc06 --- /dev/null +++ b/packages/steps/src/utils/__tests__/jsepEval-test.ts @@ -0,0 +1,41 @@ +import { jsepEval } from '../jsepEval.js'; + +const TEST_CASES = [ + ['1 + 1', 2], + ['1 + 3', 4], + ['1 +3', 4], + ['"a" + 3', 'a3'], + ['true', true], + ['false', false], + ['!false', true], + ['"a" !== "b"', true], + ['"a" === "b"', false], + ['("a" === "a") && false', false], + ['("a" === "a") || false', true], + ['this.missing', undefined], + ['this["missing"]', undefined], + ['1 + eas', 2, { eas: 1 }], + ['1 + eas.jobCount', 10, { eas: { jobCount: 9 } }], + [ + 'success() && env.NODE_ENV === "staging"', + true, + { success: () => true, env: { NODE_ENV: 'staging' } }, + ], + [ + 'success() && env.NODE_ENV === "staging"', + false, + { success: () => true, env: { NODE_ENV: 'production' } }, + ], + ['0 == 1 ? "a" : "b"', 'b'], + ['fromJSON("{\\"a\\": 1}").a', 1, { fromJSON: JSON.parse }], + ['fromJSON("{\\"a\\": 1}")[fromJSON(\'"a"\')]', 1, { fromJSON: JSON.parse }], + ['fromJSON(null).a', undefined, { fromJSON: JSON.parse }], +] as const; + +describe(jsepEval, () => { + it('works', () => { + for (const [expr, expectation, context] of TEST_CASES) { + expect(jsepEval(expr, context)).toBe(expectation); + } + }); +}); diff --git a/packages/steps/src/utils/__tests__/nullthrows-test.ts b/packages/steps/src/utils/__tests__/nullthrows-test.ts new file mode 100644 index 0000000000..b9369db358 --- /dev/null +++ b/packages/steps/src/utils/__tests__/nullthrows-test.ts @@ -0,0 +1,39 @@ +import { getError } from '../../__tests__/utils/error.js'; +import { nullthrows } from '../nullthrows.js'; + +describe(nullthrows, () => { + it('throws for null', () => { + const error = getError(() => { + nullthrows(null); + }); + expect(error).toBeInstanceOf(TypeError); + expect(error.message).toMatch(/Expected value not to be null or undefined but got null/); + }); + it('throws for undefined', () => { + const error = getError(() => { + nullthrows(undefined); + }); + expect(error).toBeInstanceOf(TypeError); + expect(error.message).toMatch(/Expected value not to be null or undefined but got undefined/); + }); + it('throws with custom message', () => { + const error = getError(() => { + nullthrows(undefined, 'blah blah'); + }); + expect(error).toBeInstanceOf(TypeError); + expect(error).toMatchObject({ + message: 'blah blah', + }); + }); + it('does not throw for falsy values', () => { + expect(() => { + nullthrows(0); + nullthrows(''); + nullthrows(false); + nullthrows(NaN); + }).not.toThrow(); + }); + it(`returns the value passed to the function if it's not null or undefined`, () => { + expect(nullthrows(123)).toBe(123); + }); +}); diff --git a/packages/steps/src/utils/__tests__/template-test.ts b/packages/steps/src/utils/__tests__/template-test.ts new file mode 100644 index 0000000000..20993312da --- /dev/null +++ b/packages/steps/src/utils/__tests__/template-test.ts @@ -0,0 +1,185 @@ +import { BuildConfigError, BuildStepRuntimeError } from '../../errors.js'; +import { getError } from '../../__tests__/utils/error.js'; +import { + findOutputPaths, + getObjectValueForInterpolation, + interpolateWithGlobalContext, + interpolateWithInputs, + interpolateWithOutputs, + parseOutputPath, +} from '../template.js'; + +describe(interpolateWithInputs, () => { + test('interpolation', () => { + const result = interpolateWithInputs('foo${ inputs.foo }', { foo: 'bar' }); + expect(result).toBe('foobar'); + }); +}); + +describe(interpolateWithOutputs, () => { + test('interpolation', () => { + const result = interpolateWithOutputs( + 'foo${ steps.abc123.foo }${ steps.abc123.bar }', + (path) => { + if (path === 'steps.abc123.foo') { + return 'bar'; + } else if (path === 'steps.abc123.bar') { + return 'baz'; + } else { + return 'x'; + } + } + ); + expect(result).toBe('foobarbaz'); + }); +}); + +describe(interpolateWithGlobalContext, () => { + test('interpolation', () => { + const result = interpolateWithGlobalContext( + 'foo${ eas.prop1.prop2.prop3.value4 }${ eas.prop1.prop2.prop3.value5 }', + (path) => { + if (path === 'eas.prop1.prop2.prop3.value4') { + return 'bar'; + } else if (path === 'eas.prop1.prop2.prop3.value5') { + return 'baz'; + } else { + return 'x'; + } + } + ); + expect(result).toBe('foobarbaz'); + }); +}); + +describe(findOutputPaths, () => { + it('returns all occurrences of output expressions in template string', () => { + const result = findOutputPaths('${ steps.test1.output1 }${steps.test4.output2}'); + expect(result.length).toBe(2); + expect(result[0]).toMatchObject({ + stepId: 'test1', + outputId: 'output1', + }); + expect(result[1]).toMatchObject({ + stepId: 'test4', + outputId: 'output2', + }); + }); +}); + +describe(parseOutputPath, () => { + it('throws an error if path does not consist of exactly two components joined with a dot', () => { + const error1 = getError(() => { + parseOutputPath('abc'); + }); + const error2 = getError(() => { + parseOutputPath('steps.def.ghi.jkl'); + }); + expect(error1).toBeInstanceOf(BuildConfigError); + expect(error1.message).toMatch(/must consist of two components joined with a dot/); + expect(error2).toBeInstanceOf(BuildConfigError); + expect(error2.message).toMatch(/must consist of two components joined with a dot/); + }); + it('returns an object with step ID and output ID', () => { + const result = parseOutputPath('steps.abc.def'); + expect(result).toMatchObject({ + stepId: 'abc', + outputId: 'def', + }); + }); +}); + +describe(getObjectValueForInterpolation, () => { + it('string property', () => { + const result = getObjectValueForInterpolation('eas.foo.bar.baz', { + eas: { + foo: { + bar: { + baz: 'qux', + }, + }, + }, + }); + expect(result).toBe('qux'); + }); + + it('number property', () => { + const result = getObjectValueForInterpolation('eas.foo.bar.baz[0]', { + eas: { + foo: { + bar: { + baz: [1, 2, 3], + }, + }, + }, + }); + expect(result).toBe(1); + }); + + it('boolean property', () => { + const result = getObjectValueForInterpolation('eas.foo.bar.baz[2].qux', { + eas: { + foo: { + bar: { + baz: [ + true, + false, + { + qux: true, + }, + ], + }, + }, + }, + }); + expect(result).toBe(true); + }); + + it('invalid property 1', () => { + const error = getError(() => { + getObjectValueForInterpolation('eas.bar', { + eas: { + foo: { + bar: { + baz: [ + true, + false, + { + qux: true, + }, + ], + }, + }, + }, + }); + }); + expect(error).toBeInstanceOf(BuildStepRuntimeError); + expect(error.message).toMatch( + /Object field "eas.bar" does not exist. Ensure you are using the correct field name./ + ); + }); + + it('invalid property 2', () => { + const error = getError(() => { + getObjectValueForInterpolation('eas.foo.bar.baz[14].qux', { + eas: { + foo: { + bar: { + baz: [ + true, + false, + { + qux: true, + }, + ], + }, + }, + }, + }); + }); + expect(error).toBeInstanceOf(BuildStepRuntimeError); + expect(error.message).toMatch( + /Object field "eas.foo.bar.baz\[14\].qux" does not exist. Ensure you are using the correct field name./ + ); + }); +}); diff --git a/packages/steps/src/utils/customFunction.ts b/packages/steps/src/utils/customFunction.ts new file mode 100644 index 0000000000..1796cc8653 --- /dev/null +++ b/packages/steps/src/utils/customFunction.ts @@ -0,0 +1,87 @@ +import path from 'path'; + +import { createContext } from 'this-file'; +import fs from 'fs-extra'; + +import { BuildStepFunction } from '../BuildStep.js'; +import { BuildStepEnv } from '../BuildStepEnv.js'; +import { SerializedBuildStepOutput } from '../BuildStepOutput.js'; +import { SerializedBuildStepContext } from '../BuildStepContext.js'; + +import { spawnAsync } from './shell/spawn.js'; + +const thisFileCtx = createContext(); + +export const SCRIPTS_PATH = path.join(thisFileCtx.dirname, '../../dist_commonjs/scripts'); + +type SerializedBuildStepInput = { serializedValue: string | undefined }; + +export interface SerializedCustomBuildFunctionArguments { + env: BuildStepEnv; + inputs: Record; + outputs: Record; + ctx: SerializedBuildStepContext; +} + +export function serializeInputs( + inputs: Parameters[1]['inputs'] +): SerializedCustomBuildFunctionArguments['inputs'] { + return Object.fromEntries( + Object.entries(inputs).map(([id, input]) => [ + id, + { serializedValue: input === undefined ? undefined : JSON.stringify(input.value) }, + ]) + ); +} + +export function deserializeInputs( + inputs: SerializedCustomBuildFunctionArguments['inputs'] +): Parameters[1]['inputs'] { + return Object.fromEntries( + Object.entries(inputs).map(([id, { serializedValue }]) => [ + id, + { value: serializedValue === undefined ? undefined : JSON.parse(serializedValue) }, + ]) + ); +} + +export function createCustomFunctionCall(rawCustomFunctionModulePath: string): BuildStepFunction { + return async (ctx, { env, inputs, outputs }) => { + let customFunctionModulePath = rawCustomFunctionModulePath; + if (!(await fs.exists(ctx.global.projectSourceDirectory))) { + const relative = path.relative( + path.resolve(ctx.global.projectSourceDirectory), + customFunctionModulePath + ); + customFunctionModulePath = path.resolve( + path.join(ctx.global.projectTargetDirectory, relative) + ); + } + const serializedArguments: SerializedCustomBuildFunctionArguments = { + env, + inputs: serializeInputs(inputs), + outputs: Object.fromEntries( + Object.entries(outputs).map(([id, output]) => [id, output.serialize()]) + ), + ctx: ctx.serialize(), + }; + try { + await spawnAsync( + 'node', + [ + path.join(SCRIPTS_PATH, 'runCustomFunction.cjs'), + customFunctionModulePath, + JSON.stringify(serializedArguments), + ], + { + logger: ctx.logger, + cwd: ctx.workingDirectory, + env, + stdio: 'pipe', + } + ); + } catch { + throw new Error(`Custom function exited with non-zero exit code.`); + } + }; +} diff --git a/packages/steps/src/utils/expodash/__tests__/duplicates-test.ts b/packages/steps/src/utils/expodash/__tests__/duplicates-test.ts new file mode 100644 index 0000000000..2e222092c6 --- /dev/null +++ b/packages/steps/src/utils/expodash/__tests__/duplicates-test.ts @@ -0,0 +1,14 @@ +import { duplicates } from '../duplicates.js'; + +describe(duplicates, () => { + it('returns empty list if there are no duplicates', () => { + expect(duplicates([1, 2, 3, 4, 5, 6])).toEqual([]); + }); + it('returns duplicates', () => { + expect(duplicates([1, 2, 2, 3, 4, 4, 4, 5])).toEqual([2, 4]); + }); + it('works with other item types too', () => { + expect(duplicates(['1', '2', '2', '3', '4', '4', '4', '5'])).toEqual(['2', '4']); + expect(duplicates([1, '2', '2', 3, 4, 4, 4, '5'])).toEqual(['2', 4]); + }); +}); diff --git a/packages/steps/src/utils/expodash/__tests__/uniq-test.ts b/packages/steps/src/utils/expodash/__tests__/uniq-test.ts new file mode 100644 index 0000000000..ad004a1be9 --- /dev/null +++ b/packages/steps/src/utils/expodash/__tests__/uniq-test.ts @@ -0,0 +1,15 @@ +import { uniq } from '../uniq.js'; + +describe(uniq, () => { + it('returns unique numbers from a list', () => { + expect(uniq([1, 2, 2, 3, 4, 2, 3])).toEqual([1, 2, 3, 4]); + }); + + it('returns unique strings from a list', () => { + expect(uniq(['hi', 'hello', 'hello', 'ola'])).toEqual(['hi', 'hello', 'ola']); + }); + + it('returns unique mixed types from a list', () => { + expect(uniq([1, 2, 2, 'hi', 'hi', 'hello', 3])).toEqual([1, 2, 'hi', 'hello', 3]); + }); +}); diff --git a/packages/steps/src/utils/expodash/duplicates.ts b/packages/steps/src/utils/expodash/duplicates.ts new file mode 100644 index 0000000000..f2f07b6f32 --- /dev/null +++ b/packages/steps/src/utils/expodash/duplicates.ts @@ -0,0 +1,12 @@ +export function duplicates(items: T[]): T[] { + const visitedItemsSet = new Set(); + const duplicatedItemsSet = new Set(); + for (const item of items) { + if (visitedItemsSet.has(item)) { + duplicatedItemsSet.add(item); + } else { + visitedItemsSet.add(item); + } + } + return [...duplicatedItemsSet]; +} diff --git a/packages/steps/src/utils/expodash/uniq.ts b/packages/steps/src/utils/expodash/uniq.ts new file mode 100644 index 0000000000..9a6a95c376 --- /dev/null +++ b/packages/steps/src/utils/expodash/uniq.ts @@ -0,0 +1,4 @@ +export function uniq(items: T[]): T[] { + const set = new Set(items); + return [...set]; +} diff --git a/packages/steps/src/utils/hashFiles.ts b/packages/steps/src/utils/hashFiles.ts new file mode 100644 index 0000000000..0018deae04 --- /dev/null +++ b/packages/steps/src/utils/hashFiles.ts @@ -0,0 +1,35 @@ +import { createHash } from 'crypto'; + +import fs from 'fs-extra'; + +/** + * Hashes the contents of multiple files and returns a combined SHA256 hash. + * @param filePaths Array of absolute file paths to hash + * @returns Combined SHA256 hash of all files, or empty string if no files exist + */ +export function hashFiles(filePaths: string[]): string { + const combinedHash = createHash('sha256'); + let hasFound = false; + + for (const filePath of filePaths) { + try { + if (fs.pathExistsSync(filePath)) { + const fileContent = fs.readFileSync(filePath); + const fileHash = createHash('sha256').update(new Uint8Array(fileContent)).digest(); + combinedHash.write(fileHash); + hasFound = true; + } + } catch (err: any) { + throw new Error(`Failed to hash file ${filePath}: ${err.message}`); + } + } + + combinedHash.end(); + const result = combinedHash.digest('hex'); + + if (!hasFound) { + return ''; + } + + return result; +} diff --git a/packages/steps/src/utils/jsepEval.ts b/packages/steps/src/utils/jsepEval.ts new file mode 100644 index 0000000000..7a07f14685 --- /dev/null +++ b/packages/steps/src/utils/jsepEval.ts @@ -0,0 +1,197 @@ +// https://github.com/Sensative/jsep-eval/blob/master/src/jsep-eval.js +// - migrated to TypeScript +// - small refactoring (splitting operators into unary/binary) +// - lack of LogicalExpression we don't need, because our version of JSEP does not expose it +// - lack of Promise wrapper we don't need + +import assert from 'assert'; + +import jsep from 'jsep'; +import get from 'lodash.get'; + +const binaryOperatorFunctions = { + '===': (a: any, b: any) => a === b, + '!==': (a: any, b: any) => a !== b, + '==': (a: any, b: any) => a == b, // eslint-disable-line + '!=': (a: any, b: any) => a != b, // eslint-disable-line + '>': (a: any, b: any) => a > b, + '<': (a: any, b: any) => a < b, + '>=': (a: any, b: any) => a >= b, + '<=': (a: any, b: any) => a <= b, + '+': (a: any, b: any) => a + b, + '-': (a: any, b: any) => a - b, + '*': (a: any, b: any) => a * b, + '/': (a: any, b: any) => a / b, + '%': (a: any, b: any) => a % b, // remainder + '**': (a: any, b: any) => a ** b, // exponentiation + '&': (a: any, b: any) => a & b, // bitwise AND + '|': (a: any, b: any) => a | b, // bitwise OR + '^': (a: any, b: any) => a ^ b, // bitwise XOR + '<<': (a: any, b: any) => a << b, // left shift + '>>': (a: any, b: any) => a >> b, // sign-propagating right shift + '>>>': (a: any, b: any) => a >>> b, // zero-fill right shift + // Let's make a home for the logical operators here as well + '||': (a: any, b: any) => a || b, + '&&': (a: any, b: any) => a && b, +}; +type BinaryOperator = keyof typeof binaryOperatorFunctions; + +const unaryOperatorFunctions = { + '!': (a: any) => !a, + '~': (a: any) => ~a, // bitwise NOT + '+': (a: any) => +a, // unary plus + '-': (a: any) => -a, // unary negation + '++': (a: any) => ++a, // increment + '--': (a: any) => --a, // decrement +}; +type UnaryOperator = keyof typeof unaryOperatorFunctions; + +function isValid( + expression: jsep.Expression, + types: T[] +): expression is jsep.CoreExpression & { type: T } { + return types.includes(expression.type as T); +} + +function getParameterPath(node: jsep.MemberExpression, context: Record): string { + // it's a MEMBER expression + // EXAMPLES: a[b] (computed) + // a.b (not computed) + const computed = node.computed; + const object = node.object; + const property = node.property; + + // object is either 'IDENTIFIER', 'MEMBER', or 'THIS' + assert( + isValid(object, ['MemberExpression', 'Identifier', 'ThisExpression']), + 'Invalid object type' + ); + assert(property, 'Member expression property is missing'); + + let objectPath = ''; + if (object.type === 'ThisExpression') { + objectPath = ''; + } else if (isValid(object, ['Identifier'])) { + objectPath = object.name; + } else { + objectPath = getParameterPath(object, context); + } + + if (computed) { + // if computed -> evaluate anew + const propertyPath = evaluateExpressionNode(property, context); + return objectPath + '[' + propertyPath + ']'; + } else if (property.type === 'Identifier') { + return (objectPath ? objectPath + '.' : '') + property.name; + } else if (property.type === 'CallExpression') { + const propertyPath = evaluateExpressionNode(property, context); + return (objectPath ? objectPath + '.' : '') + propertyPath; + } else if (property.type === 'Literal') { + return (objectPath ? objectPath + '.' : '') + `${property.value}`; + } else { + assert(isValid(property, ['MemberExpression']), 'Invalid object type'); + const propertyPath = getParameterPath(property, context); + return (objectPath ? objectPath + '.' : '') + propertyPath; + } +} + +function evaluateExpressionNode(node: jsep.Expression, context: Record): unknown { + switch (node.type as jsep.ExpressionType) { + case 'Literal': { + return (node as jsep.Literal).value; + } + case 'ThisExpression': { + return context; + } + case 'Compound': { + const compoundNode = node as jsep.Compound; + const expressions = compoundNode.body.map((el) => evaluateExpressionNode(el, context)); + return expressions.pop(); + } + case 'UnaryExpression': { + const unaryNode = node as jsep.UnaryExpression; + if (!(unaryNode.operator in unaryOperatorFunctions)) { + throw new Error(`Unsupported unary operator: ${unaryNode.operator}`); + } + const operatorFn = unaryOperatorFunctions[unaryNode.operator as UnaryOperator]; + const argument = evaluateExpressionNode(unaryNode.argument, context); + return operatorFn(argument); + } + case 'BinaryExpression': { + const binaryNode = node as jsep.BinaryExpression; + if (!(binaryNode.operator in binaryOperatorFunctions)) { + throw new Error(`Unsupported binary operator: ${binaryNode.operator}`); + } + const operator = binaryOperatorFunctions[binaryNode.operator as BinaryOperator]; + const left = evaluateExpressionNode(binaryNode.left, context); + const right = evaluateExpressionNode(binaryNode.right, context); + return operator(left, right); + } + case 'ConditionalExpression': { + const conditionalNode = node as jsep.ConditionalExpression; + const test = evaluateExpressionNode(conditionalNode.test, context); + const consequent = evaluateExpressionNode(conditionalNode.consequent, context); + const alternate = evaluateExpressionNode(conditionalNode.alternate, context); + return test ? consequent : alternate; + } + case 'CallExpression': { + const allowedCalleeTypes: jsep.ExpressionType[] = [ + 'MemberExpression', + 'Identifier', + 'ThisExpression', + ]; + const callNode = node as jsep.CallExpression; + if (!allowedCalleeTypes.includes(callNode.callee.type as jsep.ExpressionType)) { + throw new Error( + `Invalid function callee type: ${ + callNode.callee.type + }. Expected one of [${allowedCalleeTypes.join(', ')}].` + ); + } + const callee = evaluateExpressionNode(callNode.callee, context); + const args = callNode.arguments.map((arg) => evaluateExpressionNode(arg, context)); + assert(typeof callee === 'function', 'Expected a function'); + // eslint-disable-next-line prefer-spread + return callee.apply(null, args); + } + case 'Identifier': { + const identifier = (node as jsep.Identifier).name; + if (!(identifier in context)) { + throw new Error( + `Invalid identifier "${identifier}". Expected one of [${Object.keys(context).join( + ', ' + )}].` + ); + } + return get(context, identifier); + } + case 'MemberExpression': { + const memberNode = node as jsep.MemberExpression; + return get( + evaluateExpressionNode(memberNode.object, context), + getParameterPath( + { + type: 'MemberExpression', + object: { type: 'ThisExpression' }, + property: memberNode.property, + computed: false, + } as jsep.MemberExpression, + context + ) + ); + } + case 'ArrayExpression': { + const elements = (node as jsep.ArrayExpression).elements.map((el) => + el ? evaluateExpressionNode(el, context) : null + ); + return elements; + } + default: + throw new Error(`Unsupported expression type: ${node.type}`); + } +} + +export function jsepEval(expression: string, context?: Record): unknown { + const tree = jsep(expression); + return evaluateExpressionNode(tree, context ?? {}); +} diff --git a/packages/steps/src/utils/nullthrows.ts b/packages/steps/src/utils/nullthrows.ts new file mode 100644 index 0000000000..ae24559faa --- /dev/null +++ b/packages/steps/src/utils/nullthrows.ts @@ -0,0 +1,6 @@ +export function nullthrows(value: T | null | undefined, message?: string): NonNullable { + if (value != null) { + return value; + } + throw new TypeError(message ?? `Expected value not to be null or undefined but got ${value}`); +} diff --git a/packages/steps/src/utils/shell/__tests__/command-test.ts b/packages/steps/src/utils/shell/__tests__/command-test.ts new file mode 100644 index 0000000000..f5c7767c51 --- /dev/null +++ b/packages/steps/src/utils/shell/__tests__/command-test.ts @@ -0,0 +1,17 @@ +import { getShellCommandAndArgs } from '../command.js'; + +describe(getShellCommandAndArgs, () => { + test('shell command with arguments', () => { + const { command, args } = getShellCommandAndArgs( + '/bin/bash -eo pipefail', + '/path/to/script.sh' + ); + expect(command).toBe('/bin/bash'); + expect(args).toEqual(['-eo', 'pipefail', '/path/to/script.sh']); + }); + test('shell command without arguments', () => { + const { command, args } = getShellCommandAndArgs('/bin/bash', '/path/to/script.sh'); + expect(command).toBe('/bin/bash'); + expect(args).toEqual(['/path/to/script.sh']); + }); +}); diff --git a/packages/steps/src/utils/shell/bin.ts b/packages/steps/src/utils/shell/bin.ts new file mode 100644 index 0000000000..4507616d35 --- /dev/null +++ b/packages/steps/src/utils/shell/bin.ts @@ -0,0 +1,7 @@ +import path from 'path'; + +import { createContext } from 'this-file'; + +const ctx = createContext(); + +export const BIN_PATH = path.join(ctx.dirname, '../../../bin'); diff --git a/packages/steps/src/utils/shell/command.ts b/packages/steps/src/utils/shell/command.ts new file mode 100644 index 0000000000..2d1b3c7aba --- /dev/null +++ b/packages/steps/src/utils/shell/command.ts @@ -0,0 +1,15 @@ +export function getShellCommandAndArgs( + shell: string, + script?: string +): { command: string; args?: string[] } { + const splits = shell.split(' '); + const command = splits[0]; + const args = [...splits.slice(1)]; + if (script) { + args.push(script); + } + return { + command, + args, + }; +} diff --git a/packages/steps/src/utils/shell/spawn.ts b/packages/steps/src/utils/shell/spawn.ts new file mode 100644 index 0000000000..47f57e2c18 --- /dev/null +++ b/packages/steps/src/utils/shell/spawn.ts @@ -0,0 +1,45 @@ +import { IOType } from 'child_process'; + +import { pipeSpawnOutput, bunyan, PipeMode } from '@expo/logger'; +import spawnAsyncOriginal, { + SpawnResult, + SpawnPromise, + SpawnOptions as SpawnOptionsOriginal, +} from '@expo/spawn-async'; + +// We omit 'ignoreStdio' to simplify logic -- only 'stdio' governs stdio. +// We omit 'stdio' here to add further down in a logger-based union. +type SpawnOptions = Omit & { + lineTransformer?: (line: string) => string | null; + mode?: PipeMode; +} & ( + | { + // If logger is passed, we require stdio to be pipe. + logger: bunyan; + stdio: 'pipe' | [IOType, 'pipe', 'pipe', ...IOType[]]; + } + | { + // If logger is not passed, stdio can be anything. + // Defaults to inherit. + logger?: never; + stdio?: SpawnOptionsOriginal['stdio']; + } + ); +// If + +// eslint-disable-next-line async-protect/async-suffix +export function spawnAsync( + command: string, + args: string[], + allOptions: SpawnOptions = { + stdio: 'inherit', + cwd: process.cwd(), + } +): SpawnPromise { + const { logger, ...options } = allOptions; + const promise = spawnAsyncOriginal(command, args, options); + if (logger && promise.child) { + pipeSpawnOutput(logger, promise.child, options); + } + return promise; +} diff --git a/packages/steps/src/utils/template.ts b/packages/steps/src/utils/template.ts new file mode 100644 index 0000000000..9809ab261b --- /dev/null +++ b/packages/steps/src/utils/template.ts @@ -0,0 +1,167 @@ +import get from 'lodash.get'; +import cloneDeep from 'lodash.clonedeep'; + +import { BuildStepInputValueTypeName } from '../BuildStepInput.js'; +import { BuildConfigError, BuildStepRuntimeError } from '../errors.js'; + +import { nullthrows } from './nullthrows.js'; + +export const BUILD_STEP_INPUT_EXPRESSION_REGEXP = /\${\s*(inputs\.[\S]+)\s*}/; +export const BUILD_STEP_OUTPUT_EXPRESSION_REGEXP = /\${\s*(steps\.[\S]+)\s*}/; +export const BUILD_GLOBAL_CONTEXT_EXPRESSION_REGEXP = /\${\s*(eas\.[\S]+)\s*}/; +export const BUILD_STEP_OR_BUILD_GLOBAL_CONTEXT_REFERENCE_REGEX = /\${\s*((steps|eas)\.[\S]+)\s*}/; + +export function interpolateWithInputs( + templateString: string, + inputs: Record +): string { + return interpolate(templateString, BUILD_STEP_INPUT_EXPRESSION_REGEXP, inputs); +} + +export function interpolateWithOutputs( + interpolableValue: InterpolableType, + fn: (path: string) => string +): InterpolableType { + if (typeof interpolableValue === 'string') { + return interpolateStringWithOutputs(interpolableValue, fn) as InterpolableType; + } else { + return interpolateObjectWithOutputs(interpolableValue, fn) as InterpolableType; + } +} + +export function interpolateStringWithOutputs( + templateString: string, + fn: (path: string) => string +): string { + return interpolate(templateString, BUILD_STEP_OUTPUT_EXPRESSION_REGEXP, fn); +} + +export function interpolateObjectWithOutputs( + interpolableObject: object, + fn: (path: string) => string +): object { + const interpolableObjectCopy = cloneDeep(interpolableObject); + Object.keys(interpolableObject).forEach((property) => { + const propertyValue = interpolableObject[property as keyof typeof interpolableObject]; + if (['string', 'object'].includes(typeof propertyValue)) { + interpolableObjectCopy[property as keyof typeof interpolableObjectCopy] = + interpolateWithOutputs(propertyValue, fn); + } + }); + return interpolableObjectCopy; +} + +export function getObjectValueForInterpolation( + path: string, + obj: Record +): string | number | boolean | null { + const value = get(obj, path); + + if (value === undefined) { + throw new BuildStepRuntimeError( + `Object field "${path}" does not exist. Ensure you are using the correct field name.` + ); + } + + if (!isAllowedValueTypeForObjectInterpolation(value)) { + throw new BuildStepRuntimeError( + `EAS context field "${path}" is not of type ${Object.values(BuildStepInputValueTypeName).join( + ', ' + )}, or undefined. It is of type "${typeof value}". We currently only support accessing string or undefined values from the EAS context.` + ); + } + + if (value !== null && typeof value === 'object') { + return JSON.stringify(value); + } + + return value; +} + +export function interpolateWithGlobalContext( + interpolableValue: InterpolableType, + fn: (path: string) => string +): InterpolableType { + if (typeof interpolableValue === 'string') { + return interpolateStringWithGlobalContext(interpolableValue, fn) as InterpolableType; + } else { + return interpolateObjectWithGlobalContext(interpolableValue, fn) as InterpolableType; + } +} + +export function interpolateStringWithGlobalContext( + templateString: string, + fn: (path: string) => string +): string { + return interpolate(templateString, BUILD_GLOBAL_CONTEXT_EXPRESSION_REGEXP, fn); +} + +export function interpolateObjectWithGlobalContext( + templateObject: object, + fn: (path: string) => string +): object { + const templateObjectCopy = cloneDeep(templateObject); + Object.keys(templateObject).forEach((property) => { + const propertyValue = templateObject[property as keyof typeof templateObject]; + if (['string', 'object'].includes(typeof propertyValue)) { + templateObjectCopy[property as keyof typeof templateObjectCopy] = + interpolateWithGlobalContext(propertyValue, fn); + } + }); + return templateObjectCopy; +} + +function interpolate( + templateString: string, + regex: RegExp, + varsOrFn: Record | ((key: string) => string) +): string { + const matched = templateString.match(new RegExp(regex, 'g')); + if (!matched) { + return templateString; + } + let result = templateString; + for (const match of matched) { + const [, path] = nullthrows(match.match(regex)); + const value = typeof varsOrFn === 'function' ? varsOrFn(path) : varsOrFn[path.split('.')[1]]; + result = result.replace(match, value); + } + return result; +} + +function isAllowedValueTypeForObjectInterpolation( + value: unknown +): value is string | number | boolean | object | null { + return ( + typeof value === 'string' || + typeof value === 'number' || + typeof value === 'boolean' || + typeof value === 'object' || + value === null + ); +} + +interface BuildOutputPath { + stepId: string; + outputId: string; +} + +export function findOutputPaths(templateString: string): BuildOutputPath[] { + const result: BuildOutputPath[] = []; + const matches = templateString.matchAll(new RegExp(BUILD_STEP_OUTPUT_EXPRESSION_REGEXP, 'g')); + for (const match of matches) { + result.push(parseOutputPath(match[1])); + } + return result; +} + +export function parseOutputPath(outputPathWithObjectName: string): BuildOutputPath { + const splits = outputPathWithObjectName.split('.').slice(1); + if (splits.length !== 2) { + throw new BuildConfigError( + `Step output path must consist of two components joined with a dot, where first is the step ID, and second is the output name, e.g. "step3.output1". Passed: "${outputPathWithObjectName}"` + ); + } + const [stepId, outputId] = splits; + return { stepId, outputId }; +} diff --git a/packages/steps/tsconfig.build.commonjs.json b/packages/steps/tsconfig.build.commonjs.json new file mode 100644 index 0000000000..a146d9eb93 --- /dev/null +++ b/packages/steps/tsconfig.build.commonjs.json @@ -0,0 +1,8 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "module": "commonjs", + "outDir": "dist_commonjs" + }, + "exclude": ["**/__mocks__/", "**/__tests__/"] +} diff --git a/packages/steps/tsconfig.build.json b/packages/steps/tsconfig.build.json new file mode 100644 index 0000000000..85fc2fbed4 --- /dev/null +++ b/packages/steps/tsconfig.build.json @@ -0,0 +1,7 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "dist_esm" + }, + "exclude": ["**/__mocks__/", "**/__tests__/"] +} diff --git a/packages/steps/tsconfig.json b/packages/steps/tsconfig.json new file mode 100644 index 0000000000..c299f46868 --- /dev/null +++ b/packages/steps/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "moduleResolution": "node", + "target": "es2022", + "module": "es2022", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "allowSyntheticDefaultImports": true, + "declaration": true, + "composite": true, + "rootDir": "./src", + "types": ["jest"] + }, + "include": ["src"] +} diff --git a/packages/template-file/.eslintrc.json b/packages/template-file/.eslintrc.json new file mode 100644 index 0000000000..be97c53fbb --- /dev/null +++ b/packages/template-file/.eslintrc.json @@ -0,0 +1,3 @@ +{ + "extends": "../../.eslintrc.json" +} diff --git a/packages/template-file/.gitignore b/packages/template-file/.gitignore new file mode 100644 index 0000000000..b947077876 --- /dev/null +++ b/packages/template-file/.gitignore @@ -0,0 +1,2 @@ +node_modules/ +dist/ diff --git a/packages/template-file/README.md b/packages/template-file/README.md new file mode 100644 index 0000000000..dad06222aa --- /dev/null +++ b/packages/template-file/README.md @@ -0,0 +1,37 @@ +# @expo/template-file + +`@expo/template-file` provides file-level variable substitution (Mustache template style). + +## API + +```ts +templateFile(templateFilePath: string, outputFilePath: string, envs: Record): Promise +``` + +## Usage example + +```ts +import templateFile from '@expo/template-file'; + +await templateFile('abc.json.template', 'abc.json', { ABC: 123, XYZ: 789 }); +``` + +`abc.json.template` file contents: +``` +{ + "someKey": {{ ABC }}, + "anotherKey": {{ XYZ }} +} +``` + +`abc.json` file should be created with the following contents: +```json +{ + "someKey": 123, + "anotherKey": 789 +} +``` + +## Repository + +https://github.com/expo/eas-cli/tree/main/packages/template-file diff --git a/packages/template-file/jest.config.js b/packages/template-file/jest.config.js new file mode 100644 index 0000000000..97c059fc2b --- /dev/null +++ b/packages/template-file/jest.config.js @@ -0,0 +1,8 @@ +module.exports = { + preset: 'ts-jest', + testEnvironment: 'node', + rootDir: 'src', + testMatch: ['**/__tests__/*.test.ts'], + clearMocks: true, + setupFilesAfterEnv: ['/../jest/setup-tests.ts'], +}; diff --git a/packages/template-file/jest/setup-tests.ts b/packages/template-file/jest/setup-tests.ts new file mode 100644 index 0000000000..e367ec79ab --- /dev/null +++ b/packages/template-file/jest/setup-tests.ts @@ -0,0 +1,5 @@ +if (process.env.NODE_ENV !== 'test') { + throw new Error("NODE_ENV environment variable must be set to 'test'."); +} + +// Always mock: diff --git a/packages/template-file/package.json b/packages/template-file/package.json new file mode 100644 index 0000000000..e38969f077 --- /dev/null +++ b/packages/template-file/package.json @@ -0,0 +1,42 @@ +{ + "name": "@expo/template-file", + "repository": { + "type": "git", + "url": "https://github.com/expo/eas-cli.git", + "directory": "packages/template-file" + }, + "version": "1.0.260", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "files": [ + "dist" + ], + "scripts": { + "start": "yarn watch", + "watch": "tsc --watch --preserveWatchOutput", + "build": "tsc", + "prepack": "rm -rf dist && tsc -p tsconfig.build.json", + "test": "jest --config jest.config.js", + "test:watch": "jest --config jest.config.js --watch", + "clean": "rm -rf node_modules dist coverage" + }, + "author": "Expo ", + "bugs": "https://github.com/expo/eas-cli/issues", + "license": "BUSL-1.1", + "dependencies": { + "lodash": "^4.17.21" + }, + "devDependencies": { + "@types/jest": "^29.5.12", + "@types/lodash": "^4.17.4", + "@types/lodash.template": "^4.5.3", + "@types/node": "20.14.2", + "jest": "^29.7.0", + "ts-jest": "^29.1.4", + "typescript": "^5.5.4" + }, + "volta": { + "node": "20.14.0", + "yarn": "1.22.21" + } +} diff --git a/packages/template-file/src/__tests__/example.json.template b/packages/template-file/src/__tests__/example.json.template new file mode 100644 index 0000000000..ce077b16b7 --- /dev/null +++ b/packages/template-file/src/__tests__/example.json.template @@ -0,0 +1,4 @@ +{ + "someKey": {{ SOME_KEY }}, + "anotherKey": {{ ANOTHER_KEY }} +} diff --git a/packages/template-file/src/__tests__/index.test.ts b/packages/template-file/src/__tests__/index.test.ts new file mode 100644 index 0000000000..a88c710e01 --- /dev/null +++ b/packages/template-file/src/__tests__/index.test.ts @@ -0,0 +1,58 @@ +import os from 'os'; +import path from 'path'; +import fs from 'fs/promises'; + +import { templateFile, templateString } from '../index'; + +const templatePath = path.join(__dirname, 'example.json.template'); + +describe('templateFile', () => { + const outputFile = path.join(os.tmpdir(), 'output.json'); + + afterEach(async () => { + await fs.rm(outputFile, { force: true }); + }); + + it('should create an output file with the filled-out template', async () => { + await templateFile(templatePath, { SOME_KEY: 123, ANOTHER_KEY: 456 }, outputFile); + const outputFileContents = await fs.readFile(outputFile, 'utf8'); + const outputFileJson = JSON.parse(outputFileContents); + expect(outputFileJson).toEqual({ someKey: 123, anotherKey: 456 }); + }); + + it('should throw an error if some variables are missing', async () => { + const templateFilePromise = templateFile(templatePath, {}, outputFile); + await expect(templateFilePromise).rejects.toThrow(/is not defined/); + }); +}); + +describe('templateString', () => { + it('should interpolate variables using mustache syntax by default', () => { + const input = 'Hello {{ name }}!'; + const result = templateString({ input, vars: { name: 'World' } }); + expect(result).toBe('Hello World!'); + }); + + it('should interpolate variables using lodash syntax when mustache is false', () => { + const input = 'Hello <%= name %>!'; + const result = templateString({ input, vars: { name: 'World' }, mustache: false }); + expect(result).toBe('Hello World!'); + }); + + it('should handle missing variables by throwing an error', () => { + const input = 'Hello {{ name }}!'; + expect(() => templateString({ input, vars: {} })).toThrow(/name is not defined/); + }); + + it('should interpolate multiple variables', () => { + const input = '{{ greeting }} {{ name }}!'; + const result = templateString({ input, vars: { greeting: 'Hello', name: 'World' } }); + expect(result).toBe('Hello World!'); + }); + + it('should handle complex objects in variables', () => { + const input = 'Hello {{ user.name }}!'; + const result = templateString({ input, vars: { user: { name: 'World' } } }); + expect(result).toBe('Hello World!'); + }); +}); diff --git a/packages/template-file/src/index.ts b/packages/template-file/src/index.ts new file mode 100644 index 0000000000..f97240dc09 --- /dev/null +++ b/packages/template-file/src/index.ts @@ -0,0 +1,42 @@ +import fs from 'fs/promises'; + +// We can't use lodash/template because templates expect to be able to do `_.forEach`. +import _ from 'lodash'; + +export function templateString({ + input, + vars, + mustache = true, +}: { + input: string; + vars: Record; + mustache?: boolean; +}): string { + const compiledTemplate = _.template( + input, + mustache + ? { + interpolate: /{{([\s\S]+?)}}/g, + } + : undefined + ); + return compiledTemplate(vars); +} + +export async function templateFile( + templateFilePath: string, + vars: Record, + outputFilePath?: string, + options: { mustache?: boolean } = {} +): Promise { + const templateContent = await fs.readFile(templateFilePath, 'utf8'); + const outputFileContents = templateString({ input: templateContent, vars, ...options }); + + if (outputFilePath) { + await fs.writeFile(outputFilePath, outputFileContents); + } else { + return outputFileContents; + } +} + +export default templateFile; diff --git a/packages/template-file/tsconfig.build.json b/packages/template-file/tsconfig.build.json new file mode 100644 index 0000000000..2ef7345294 --- /dev/null +++ b/packages/template-file/tsconfig.build.json @@ -0,0 +1,4 @@ +{ + "extends": "./tsconfig.json", + "exclude": ["src/**/__mocks__/**/*.ts", "src/**/__tests__/**/*.ts"] +} diff --git a/packages/template-file/tsconfig.json b/packages/template-file/tsconfig.json new file mode 100644 index 0000000000..88c1c45a97 --- /dev/null +++ b/packages/template-file/tsconfig.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "moduleResolution": "node", + "target": "es2022", + "module": "commonjs", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "declaration": true, + "composite": true, + "rootDir": "./src", + "outDir": "./dist" + }, + "include": [ + "src/**/*" + ] +} diff --git a/packages/turtle-spawn/.eslintrc.json b/packages/turtle-spawn/.eslintrc.json new file mode 100644 index 0000000000..be97c53fbb --- /dev/null +++ b/packages/turtle-spawn/.eslintrc.json @@ -0,0 +1,3 @@ +{ + "extends": "../../.eslintrc.json" +} diff --git a/packages/turtle-spawn/.gitignore b/packages/turtle-spawn/.gitignore new file mode 100644 index 0000000000..b947077876 --- /dev/null +++ b/packages/turtle-spawn/.gitignore @@ -0,0 +1,2 @@ +node_modules/ +dist/ diff --git a/packages/turtle-spawn/README.md b/packages/turtle-spawn/README.md new file mode 100644 index 0000000000..2adac883a7 --- /dev/null +++ b/packages/turtle-spawn/README.md @@ -0,0 +1,7 @@ +# @expo/turtle-spawn + +`@expo/turtle-spawn` is a wrapper around `@expo/spawn-async` library. + +## Repository + +https://github.com/expo/eas-cli/tree/main/packages/turtle-spawn diff --git a/packages/turtle-spawn/jest.config.js b/packages/turtle-spawn/jest.config.js new file mode 100644 index 0000000000..97c059fc2b --- /dev/null +++ b/packages/turtle-spawn/jest.config.js @@ -0,0 +1,8 @@ +module.exports = { + preset: 'ts-jest', + testEnvironment: 'node', + rootDir: 'src', + testMatch: ['**/__tests__/*.test.ts'], + clearMocks: true, + setupFilesAfterEnv: ['/../jest/setup-tests.ts'], +}; diff --git a/packages/turtle-spawn/jest/setup-tests.ts b/packages/turtle-spawn/jest/setup-tests.ts new file mode 100644 index 0000000000..e367ec79ab --- /dev/null +++ b/packages/turtle-spawn/jest/setup-tests.ts @@ -0,0 +1,5 @@ +if (process.env.NODE_ENV !== 'test') { + throw new Error("NODE_ENV environment variable must be set to 'test'."); +} + +// Always mock: diff --git a/packages/turtle-spawn/package.json b/packages/turtle-spawn/package.json new file mode 100644 index 0000000000..af4dff636f --- /dev/null +++ b/packages/turtle-spawn/package.json @@ -0,0 +1,41 @@ +{ + "name": "@expo/turtle-spawn", + "repository": { + "type": "git", + "url": "https://github.com/expo/eas-cli.git", + "directory": "packages/turtle-spawn" + }, + "version": "1.0.260", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "files": [ + "dist" + ], + "scripts": { + "start": "yarn watch", + "watch": "tsc --watch --preserveWatchOutput", + "build": "tsc", + "prepack": "rm -rf dist && tsc -p tsconfig.build.json", + "test": "jest --config jest.config.js --passWithNoTests", + "test:watch": "jest --config jest.config.js --watch", + "clean": "rm -rf node_modules dist coverage" + }, + "author": "Expo ", + "bugs": "https://github.com/expo/eas-cli/issues", + "license": "BUSL-1.1", + "dependencies": { + "@expo/logger": "1.0.260", + "@expo/spawn-async": "^1.7.2" + }, + "devDependencies": { + "@types/jest": "^29.5.12", + "@types/node": "20.14.2", + "jest": "^29.7.0", + "ts-jest": "^29.1.4", + "typescript": "^5.5.4" + }, + "volta": { + "node": "20.14.0", + "yarn": "1.22.21" + } +} diff --git a/packages/turtle-spawn/src/index.ts b/packages/turtle-spawn/src/index.ts new file mode 100644 index 0000000000..021d067f1f --- /dev/null +++ b/packages/turtle-spawn/src/index.ts @@ -0,0 +1,33 @@ +import { pipeSpawnOutput, bunyan, PipeOptions } from '@expo/logger'; +import spawnAsync, { + SpawnResult, + SpawnPromise, + SpawnOptions as SpawnAsyncOptions, +} from '@expo/spawn-async'; + +type SpawnOptions = SpawnAsyncOptions & + PipeOptions & { + logger?: bunyan; + }; + +function spawn( + command: string, + args: string[], + _options: SpawnOptions = { + stdio: 'inherit', + cwd: process.cwd(), + } +): SpawnPromise { + const { logger, ...options } = _options; + if (logger) { + options.stdio = 'pipe'; + } + const promise = spawnAsync(command, args, options); + if (logger && promise.child) { + pipeSpawnOutput(logger, promise.child, options); + } + return promise; +} + +export default spawn; +export { SpawnOptions, SpawnResult, SpawnPromise }; diff --git a/packages/turtle-spawn/tsconfig.build.json b/packages/turtle-spawn/tsconfig.build.json new file mode 100644 index 0000000000..2ef7345294 --- /dev/null +++ b/packages/turtle-spawn/tsconfig.build.json @@ -0,0 +1,4 @@ +{ + "extends": "./tsconfig.json", + "exclude": ["src/**/__mocks__/**/*.ts", "src/**/__tests__/**/*.ts"] +} diff --git a/packages/turtle-spawn/tsconfig.json b/packages/turtle-spawn/tsconfig.json new file mode 100644 index 0000000000..c0b18842df --- /dev/null +++ b/packages/turtle-spawn/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "moduleResolution": "node", + "target": "es2022", + "module": "commonjs", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "declaration": true, + "composite": true, + "outDir": "./dist", + "rootDir": "./src" + }, + "include": ["src/**/*"] +} diff --git a/packages/worker/package.json b/packages/worker/package.json index 7e7d9dc3a6..69ac36e55f 100644 --- a/packages/worker/package.json +++ b/packages/worker/package.json @@ -22,18 +22,18 @@ }, "dependencies": { "@babel/core": "7.24.7", - "@expo/build-tools": "1.0.252", + "@expo/build-tools": "1.0.260", "@expo/config-plugins": "8.0.8", "@expo/config-types": "52.0.1", - "@expo/downloader": "1.0.221", - "@expo/eas-build-job": "1.0.252", - "@expo/logger": "1.0.221", + "@expo/downloader": "1.0.260", + "@expo/eas-build-job": "1.0.260", + "@expo/logger": "1.0.260", "@expo/results": "1.0.0", "@expo/rudder-sdk-node": "1.1.1", "@expo/spawn-async": "1.7.2", - "@expo/steps": "1.0.252", - "@expo/template-file": "1.0.252", - "@expo/turtle-spawn": "1.0.221", + "@expo/steps": "1.0.260", + "@expo/template-file": "1.0.260", + "@expo/turtle-spawn": "1.0.260", "@hapi/boom": "10.0.1", "@sentry/node": "7.77.0", "check-disk-space": "3.4.0", diff --git a/packages/worker/src/env.ts b/packages/worker/src/env.ts index 032003de56..cc26720535 100644 --- a/packages/worker/src/env.ts +++ b/packages/worker/src/env.ts @@ -13,7 +13,7 @@ import { import { getAccessedEnvs } from './utils/env'; // keep in sync with local-build-plugin env vars -// https://github.com/expo/eas-build/blob/main/packages/local-build-plugin/src/build.ts +// see packages/local-build-plugin/src/build.ts export function getBuildEnv({ job, projectId, diff --git a/packages/worker/tsconfig.build.json b/packages/worker/tsconfig.build.json index 4aff838491..9a459f31d8 100644 --- a/packages/worker/tsconfig.build.json +++ b/packages/worker/tsconfig.build.json @@ -2,6 +2,7 @@ "extends": "./tsconfig.json", "compilerOptions": { "noEmit": false, + "rootDir": "./src", "outDir": "./dist" }, "exclude": ["src/**/__mocks__/**/*.ts", "src/**/__unit__/**/*.ts", "src/**/__integration__/**/*.ts", "src/**/__system__/**/*.ts"] diff --git a/yarn.lock b/yarn.lock index f73045d5c0..9f05bff3be 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1546,43 +1546,6 @@ resolved "https://registry.yarnpkg.com/@expo/apple-utils/-/apple-utils-2.1.12.tgz#9c40a6294820e59d8b1a677318dccb1ccefc5b00" integrity sha512-ugpL2URxNFxIRw943AIpX3dcvL5rhNCumpL8XTosYqZPyQQ7JRLVNd1m8FNyPg3pmFrz8M4tSucY2pt2ATuKOA== -"@expo/build-tools@1.0.252": - version "1.0.252" - resolved "https://registry.yarnpkg.com/@expo/build-tools/-/build-tools-1.0.252.tgz#589d438c17bac0e8d4da9e7cdc448cca1a3315ee" - integrity sha512-jkW0s7zJs5Y4ySivRDyUx/CEADxU4JYt/IifV94TrkFIc3IchNuPLjYFNo/D/NwRYUy26h7xVVBqHWrnE8wF/Q== - dependencies: - "@expo/config" "10.0.6" - "@expo/config-plugins" "9.0.12" - "@expo/downloader" "1.0.221" - "@expo/eas-build-job" "1.0.252" - "@expo/env" "^0.4.0" - "@expo/logger" "1.0.221" - "@expo/package-manager" "1.7.0" - "@expo/plist" "^0.2.0" - "@expo/results" "^1.0.0" - "@expo/steps" "1.0.252" - "@expo/template-file" "1.0.252" - "@expo/turtle-spawn" "1.0.221" - "@expo/xcpretty" "^4.3.1" - "@google-cloud/storage" "^7.11.2" - "@urql/core" "^6.0.1" - fast-glob "^3.3.2" - fs-extra "^11.2.0" - gql.tada "^1.8.13" - joi "^17.13.1" - lodash "^4.17.21" - node-fetch "^2.7.0" - node-forge "^1.3.1" - nullthrows "^1.1.1" - plist "^3.1.0" - promise-limit "^2.7.0" - promise-retry "^2.0.1" - resolve-from "^5.0.0" - retry "^0.13.1" - semver "^7.6.2" - tar "^7.4.3" - yaml "^2.8.1" - "@expo/bunyan@^4.0.0": version "4.0.0" resolved "https://registry.yarnpkg.com/@expo/bunyan/-/bunyan-4.0.0.tgz#be0c1de943c7987a9fbd309ea0b1acd605890c7b" @@ -1759,44 +1722,6 @@ slugify "^1.3.4" sucrase "3.35.0" -"@expo/downloader@1.0.221": - version "1.0.221" - resolved "https://registry.yarnpkg.com/@expo/downloader/-/downloader-1.0.221.tgz#3c028c0793aa7b2dab73d4c38ec461687e98e035" - integrity sha512-1bNH8IskcTQsvNSYWkORn6K5v2kmZrpkh93NkxAMs2GeqEQ/8Dc3CFqb4hfiKG1ywXLg8Xd6/MsRACzrnZyvPQ== - dependencies: - fs-extra "^11.2.0" - got "11.8.5" - -"@expo/eas-build-job@1.0.231": - version "1.0.231" - resolved "https://registry.yarnpkg.com/@expo/eas-build-job/-/eas-build-job-1.0.231.tgz#5c96045276da1e521794523204e9da6051e4dda1" - integrity sha512-jktsZ7IJYcC9lbgUWftbFCpfrhcGwJX4q0qSubUrqvFTMuUyLjCzJqyIZ4znbXLXfJSUPDJNAd4H0wBqKJA9kA== - dependencies: - "@expo/logger" "1.0.221" - joi "^17.13.1" - semver "^7.6.2" - zod "^4.1.3" - -"@expo/eas-build-job@1.0.243": - version "1.0.243" - resolved "https://registry.yarnpkg.com/@expo/eas-build-job/-/eas-build-job-1.0.243.tgz#2b390ca1a84b4a4ea06630cf875b96c0954094ab" - integrity sha512-wLU9q0pzIKibr4jLMV/U6BYszk5T85p/WmRYUIqH7nhmy2dDUJHz0WlP/i/ZWAsnrHplwidbCZvZGoxwgJsISA== - dependencies: - "@expo/logger" "1.0.221" - joi "^17.13.1" - semver "^7.6.2" - zod "^4.1.3" - -"@expo/eas-build-job@1.0.252": - version "1.0.252" - resolved "https://registry.yarnpkg.com/@expo/eas-build-job/-/eas-build-job-1.0.252.tgz#0b6aff23bc20648382acee6f3f658a14eb26307c" - integrity sha512-v6O5Sxm1kJmGq9RdZnHhrinjHAxEKd/fx0D8pdGPS/edJFdVX32w6TEOApv/Yjp8KiQyWWuvGkPyaGJlmlEbXw== - dependencies: - "@expo/logger" "1.0.221" - joi "^17.13.1" - semver "^7.6.2" - zod "^4.1.3" - "@expo/env@^0.4.0": version "0.4.2" resolved "https://registry.yarnpkg.com/@expo/env/-/env-0.4.2.tgz#911709933e6fc1b45b3d2efdb10ca2c52fac7e91" @@ -1878,14 +1803,6 @@ json5 "^2.2.3" write-file-atomic "^2.3.0" -"@expo/logger@1.0.221": - version "1.0.221" - resolved "https://registry.yarnpkg.com/@expo/logger/-/logger-1.0.221.tgz#bd6ef6e03b3c4bf0872843d8b2a83556892178c0" - integrity sha512-arNkhzq4Ludz9J7PIYWTVYXuT1bO/A8prR8HYAviKuF6fEZfitemNVriGSetQASOE8Vnv3lmBZv6YIwLiFuv/Q== - dependencies: - "@types/bunyan" "^1.8.11" - bunyan "^1.8.15" - "@expo/multipart-body-parser@2.0.0": version "2.0.0" resolved "https://registry.yarnpkg.com/@expo/multipart-body-parser/-/multipart-body-parser-2.0.0.tgz#dcd2034f3bbc29b9a6fa6ff31e5ba0caea0a2b38" @@ -2000,6 +1917,14 @@ semver "^7.6.0" xml2js "0.6.0" +"@expo/repack-app@~0.2.5": + version "0.2.16" + resolved "https://registry.yarnpkg.com/@expo/repack-app/-/repack-app-0.2.16.tgz#83593aa56e49efcc756887459c0fe7f74f9f03ab" + integrity sha512-cHVqJOoWArVGJwvFPa3l9L4PnThmdS1k2BiBUgbiRsbDK/1ZEI3oUcZ3MPt4zAgi9H1zLEn9lG41vGxSicoZLg== + dependencies: + commander "^13.1.0" + picocolors "^1.1.1" + "@expo/results@1.0.0", "@expo/results@^1.0.0": version "1.0.0" resolved "https://registry.yarnpkg.com/@expo/results/-/results-1.0.0.tgz#fd4b22f936ceafce23b04799f54b87fe2a9e18d1" @@ -2030,62 +1955,11 @@ dependencies: cross-spawn "^7.0.3" -"@expo/steps@1.0.231": - version "1.0.231" - resolved "https://registry.yarnpkg.com/@expo/steps/-/steps-1.0.231.tgz#28fece40d97abb4ccddfad2ea003fa98d414f08a" - integrity sha512-o7Wj6W32S4H4geWC0b3RL5finHmiPT+e8SMP4/4Il2R0oc9er8jLXaj1tQ3fTZvpnlqoMWcFn5fAvaGnxfYDPA== - dependencies: - "@expo/eas-build-job" "1.0.231" - "@expo/logger" "1.0.221" - "@expo/spawn-async" "^1.7.2" - arg "^5.0.2" - fs-extra "^11.2.0" - joi "^17.13.1" - jsep "^1.3.8" - lodash.clonedeep "^4.5.0" - lodash.get "^4.4.2" - this-file "^2.0.3" - uuid "^9.0.1" - yaml "^2.4.3" - -"@expo/steps@1.0.252": - version "1.0.252" - resolved "https://registry.yarnpkg.com/@expo/steps/-/steps-1.0.252.tgz#979dfb75c679426c05f013967782a467d5fb7ac6" - integrity sha512-7htW+ljVWo+OgQHR6n3ghUHfJgpOtnzgEf1LlqIoVdVpWpppEEAszfevTYIWIPbJ2azayP9ZVOh2MwLFdDfAnQ== - dependencies: - "@expo/eas-build-job" "1.0.252" - "@expo/logger" "1.0.221" - "@expo/spawn-async" "^1.7.2" - arg "^5.0.2" - fs-extra "^11.2.0" - joi "^17.13.1" - jsep "^1.3.8" - lodash.clonedeep "^4.5.0" - lodash.get "^4.4.2" - this-file "^2.0.3" - uuid "^9.0.1" - yaml "^2.4.3" - -"@expo/template-file@1.0.252": - version "1.0.252" - resolved "https://registry.yarnpkg.com/@expo/template-file/-/template-file-1.0.252.tgz#abd2c42a87f01d052777ec35d573ce27ff4b1a3b" - integrity sha512-Uc72CYsa6N8uUSHWTnej8M58F50M6znkpZHd4FjhwiNimOdf+vWv7w7dMY7evooXCyx6nO+x4oZ+nGE1M4n9KA== - dependencies: - lodash "^4.17.21" - "@expo/timeago.js@1.0.0": version "1.0.0" resolved "https://registry.yarnpkg.com/@expo/timeago.js/-/timeago.js-1.0.0.tgz#8fb2b17e93e7a8d28387a4d292af12e071040045" integrity sha512-PD45CGlCL8kG0U3YcH1NvYxQThw5XAS7qE9bgP4L7dakm8lsMz+p8BQ1IjBFMmImawVWsV3py6JZINaEebXLnw== -"@expo/turtle-spawn@1.0.221": - version "1.0.221" - resolved "https://registry.yarnpkg.com/@expo/turtle-spawn/-/turtle-spawn-1.0.221.tgz#f33dcd18434ebb6456aa4f17164911b69b391c85" - integrity sha512-rBd1gOpViJA5hh+1lW6R8yZ8K1FW1Wpry8zMmAhHthPqzJiqHbHoDAlXyPSdouKtpHx8xk+L6H2GEE9h0YWCBg== - dependencies: - "@expo/logger" "1.0.221" - "@expo/spawn-async" "^1.7.2" - "@expo/xcpretty@^4.3.1": version "4.3.2" resolved "https://registry.yarnpkg.com/@expo/xcpretty/-/xcpretty-4.3.2.tgz#12dba1295167a9c8dde4be783d74f7e81648ca5d" @@ -3150,6 +3024,51 @@ "@jridgewell/resolve-uri" "^3.0.3" "@jridgewell/sourcemap-codec" "^1.4.10" +"@jsonjoy.com/base64@^1.1.2": + version "1.1.2" + resolved "https://registry.yarnpkg.com/@jsonjoy.com/base64/-/base64-1.1.2.tgz#cf8ea9dcb849b81c95f14fc0aaa151c6b54d2578" + integrity sha512-q6XAnWQDIMA3+FTiOYajoYqySkO+JSat0ytXGSuRdq9uXE7o92gzuQwQM14xaCRlBLGq3v5miDGC4vkVTn54xA== + +"@jsonjoy.com/buffers@^1.0.0", "@jsonjoy.com/buffers@^1.2.0": + version "1.2.1" + resolved "https://registry.yarnpkg.com/@jsonjoy.com/buffers/-/buffers-1.2.1.tgz#8d99c7f67eaf724d3428dfd9826c6455266a5c83" + integrity sha512-12cdlDwX4RUM3QxmUbVJWqZ/mrK6dFQH4Zxq6+r1YXKXYBNgZXndx2qbCJwh3+WWkCSn67IjnlG3XYTvmvYtgA== + +"@jsonjoy.com/codegen@^1.0.0": + version "1.0.0" + resolved "https://registry.yarnpkg.com/@jsonjoy.com/codegen/-/codegen-1.0.0.tgz#5c23f796c47675f166d23b948cdb889184b93207" + integrity sha512-E8Oy+08cmCf0EK/NMxpaJZmOxPqM+6iSe2S4nlSBrPZOORoDJILxtbSUEDKQyTamm/BVAhIGllOBNU79/dwf0g== + +"@jsonjoy.com/json-pack@^1.11.0": + version "1.21.0" + resolved "https://registry.yarnpkg.com/@jsonjoy.com/json-pack/-/json-pack-1.21.0.tgz#93f8dd57fe3a3a92132b33d1eb182dcd9e7629fa" + integrity sha512-+AKG+R2cfZMShzrF2uQw34v3zbeDYUqnQ+jg7ORic3BGtfw9p/+N6RJbq/kkV8JmYZaINknaEQ2m0/f693ZPpg== + dependencies: + "@jsonjoy.com/base64" "^1.1.2" + "@jsonjoy.com/buffers" "^1.2.0" + "@jsonjoy.com/codegen" "^1.0.0" + "@jsonjoy.com/json-pointer" "^1.0.2" + "@jsonjoy.com/util" "^1.9.0" + hyperdyperid "^1.2.0" + thingies "^2.5.0" + tree-dump "^1.1.0" + +"@jsonjoy.com/json-pointer@^1.0.2": + version "1.0.2" + resolved "https://registry.yarnpkg.com/@jsonjoy.com/json-pointer/-/json-pointer-1.0.2.tgz#049cb530ac24e84cba08590c5e36b431c4843408" + integrity sha512-Fsn6wM2zlDzY1U+v4Nc8bo3bVqgfNTGcn6dMgs6FjrEnt4ZCe60o6ByKRjOGlI2gow0aE/Q41QOigdTqkyK5fg== + dependencies: + "@jsonjoy.com/codegen" "^1.0.0" + "@jsonjoy.com/util" "^1.9.0" + +"@jsonjoy.com/util@^1.9.0": + version "1.9.0" + resolved "https://registry.yarnpkg.com/@jsonjoy.com/util/-/util-1.9.0.tgz#7ee95586aed0a766b746cd8d8363e336c3c47c46" + integrity sha512-pLuQo+VPRnN8hfPqUTLTHk126wuYdXVxE6aDmjSeV4NCAgyxWbiOIeNJVtID3h1Vzpoi9m4jXezf73I6LgabgQ== + dependencies: + "@jsonjoy.com/buffers" "^1.0.0" + "@jsonjoy.com/codegen" "^1.0.0" + "@kamilkisiela/fast-url-parser@^1.1.4": version "1.1.4" resolved "https://registry.yarnpkg.com/@kamilkisiela/fast-url-parser/-/fast-url-parser-1.1.4.tgz#9d68877a489107411b953c54ea65d0658b515809" @@ -4562,7 +4481,7 @@ dependencies: "@types/node" "*" -"@types/fs-extra@11.0.4": +"@types/fs-extra@11.0.4", "@types/fs-extra@^11.0.4": version "11.0.4" resolved "https://registry.yarnpkg.com/@types/fs-extra/-/fs-extra-11.0.4.tgz#e16a863bb8843fba8c5004362b5a73e17becca45" integrity sha512-yTbItCNreRooED33qjunPthRcSjERP1r4MqCZc7wv0u2sUkzTFp45tgUfS5+r7FrZPdmCCNflLhVSP/o+SemsQ== @@ -4584,6 +4503,11 @@ dependencies: "@types/node" "*" +"@types/hapi__joi@^17.1.14": + version "17.1.15" + resolved "https://registry.yarnpkg.com/@types/hapi__joi/-/hapi__joi-17.1.15.tgz#f1daacb67386fb6e86393da811971721d0437e28" + integrity sha512-Ehq/YQB0ZqZGObrGngztxtThTiShrG0jlqyUSsNK3cebJSoyYgE/hdZvYt6lH4Wimi28RowDwnr87XseiemqAg== + "@types/http-assert@*": version "1.5.6" resolved "https://registry.yarnpkg.com/@types/http-assert/-/http-assert-1.5.6.tgz#b6b657c38a2350d21ce213139f33b03b2b5fa431" @@ -4631,6 +4555,14 @@ expect "^29.0.0" pretty-format "^29.0.0" +"@types/jest@^29.5.12": + version "29.5.14" + resolved "https://registry.yarnpkg.com/@types/jest/-/jest-29.5.14.tgz#2b910912fa1d6856cadcd0c1f95af7df1d6049e5" + integrity sha512-ZN+4sdnLUbo8EVvVc2ao0GFW6oVrQRPn4K2lglySj7APvSrgzxHiNNK99us4WDMi57xxA2yggblIAMNhXOotLQ== + dependencies: + expect "^29.0.0" + pretty-format "^29.0.0" + "@types/js-yaml@^4.0.0": version "4.0.5" resolved "https://registry.yarnpkg.com/@types/js-yaml/-/js-yaml-4.0.5.tgz#738dd390a6ecc5442f35e7f03fa1431353f7e138" @@ -4714,6 +4646,32 @@ "@types/koa-compose" "*" "@types/node" "*" +"@types/lodash.clonedeep@4.5.9": + version "4.5.9" + resolved "https://registry.yarnpkg.com/@types/lodash.clonedeep/-/lodash.clonedeep-4.5.9.tgz#ea48276c7cc18d080e00bb56cf965bcceb3f0fc1" + integrity sha512-19429mWC+FyaAhOLzsS8kZUsI+/GmBAQ0HFiCPsKGU+7pBXOQWhyrY6xNNDwUSX8SMZMJvuFVMF9O5dQOlQK9Q== + dependencies: + "@types/lodash" "*" + +"@types/lodash.get@4.4.9": + version "4.4.9" + resolved "https://registry.yarnpkg.com/@types/lodash.get/-/lodash.get-4.4.9.tgz#6390714bf688321d9a445cbc8e90220635649713" + integrity sha512-J5dvW98sxmGnamqf+/aLP87PYXyrha9xIgc2ZlHl6OHMFR2Ejdxep50QfU0abO1+CH6+ugx+8wEUN1toImAinA== + dependencies: + "@types/lodash" "*" + +"@types/lodash.template@^4.5.3": + version "4.5.3" + resolved "https://registry.yarnpkg.com/@types/lodash.template/-/lodash.template-4.5.3.tgz#1174483eaa761a76a9d68c4adbee4c4e2742f329" + integrity sha512-Mo0UYKLu1oXgkV9TVoXZLlXXjyIXlW7ZQRxi/4gQJmzJr63dmicE8gG0OkPjYTKBrBic852q0JzqrtNUWLBIyA== + dependencies: + "@types/lodash" "*" + +"@types/lodash@*", "@types/lodash@^4.17.4": + version "4.17.21" + resolved "https://registry.yarnpkg.com/@types/lodash/-/lodash-4.17.21.tgz#b806831543d696b14f8112db600ea9d3a1df6ea4" + integrity sha512-FOvQ0YPD5NOfPgMzJihoT+Za5pdkDJWcbpuj1DjaKZIr/gxodQjY/uWEFlTNqW2ugXHUiL8lRQgw63dzKHZdeQ== + "@types/lodash@4.17.4": version "4.17.4" resolved "https://registry.yarnpkg.com/@types/lodash/-/lodash-4.17.4.tgz#0303b64958ee070059e3a7184048a55159fe20b7" @@ -4762,6 +4720,14 @@ "@types/node" "*" form-data "^4.0.0" +"@types/node-fetch@^2.6.11": + version "2.6.13" + resolved "https://registry.yarnpkg.com/@types/node-fetch/-/node-fetch-2.6.13.tgz#e0c9b7b5edbdb1b50ce32c127e85e880872d56ee" + integrity sha512-QGpRVpzSaUs30JBSGPjOg4Uveu384erbHBoT1zeONvyCfwQxIkUshLAOqN/k9EjGviPRmWTTe6aH2qySWKTVSw== + dependencies: + "@types/node" "*" + form-data "^4.0.4" + "@types/node-forge@1.3.1": version "1.3.1" resolved "https://registry.yarnpkg.com/@types/node-forge/-/node-forge-1.3.1.tgz#49e44432c306970b4e900c3b214157c480af19fa" @@ -4769,6 +4735,13 @@ dependencies: "@types/node" "*" +"@types/node-forge@^1.3.11": + version "1.3.14" + resolved "https://registry.yarnpkg.com/@types/node-forge/-/node-forge-1.3.14.tgz#006c2616ccd65550560c2757d8472eb6d3ecea0b" + integrity sha512-mhVF2BnD4BO+jtOp7z1CdzaK4mbuK0LLQYAvdOLqHTavxFNq4zA1EmYkpnFjP8HOUzedfQkRnp0E2ulSAYSzAw== + dependencies: + "@types/node" "*" + "@types/node-os-utils@1.3.0": version "1.3.0" resolved "https://registry.yarnpkg.com/@types/node-os-utils/-/node-os-utils-1.3.0.tgz#df06d65292a4ccf99d332e39c4f64e94c538fad5" @@ -4781,6 +4754,13 @@ dependencies: undici-types "~5.26.4" +"@types/node@20.14.2": + version "20.14.2" + resolved "https://registry.yarnpkg.com/@types/node/-/node-20.14.2.tgz#a5f4d2bcb4b6a87bffcaa717718c5a0f208f4a18" + integrity sha512-xyu6WAMVwv6AKFLB+e/7ySZVr/0zLCzOa7rSpq6jNwpqOrUbcACDWC+53d4n2QHOnDou0fbIsg8wZu/sxrnI4Q== + dependencies: + undici-types "~5.26.4" + "@types/node@20.14.8": version "20.14.8" resolved "https://registry.yarnpkg.com/@types/node/-/node-20.14.8.tgz#45c26a2a5de26c3534a9504530ddb3b27ce031ac" @@ -4803,6 +4783,14 @@ resolved "https://registry.yarnpkg.com/@types/parse-json/-/parse-json-4.0.0.tgz#2f8bb441434d163b35fb8ffdccd7138927ffb8c0" integrity sha512-//oorEZjL6sbPcKUaCdIGlIUeH26mgzimjBB77G6XRgnDl/L5wOnpyBGRe/Mmf5CVW3PwEBE1NjiMZ/ssFh4wA== +"@types/plist@^3.0.5": + version "3.0.5" + resolved "https://registry.yarnpkg.com/@types/plist/-/plist-3.0.5.tgz#9a0c49c0f9886c8c8696a7904dd703f6284036e0" + integrity sha512-E6OCaRmAe4WDmWNsL/9RMqdkkzDCY1etutkflWk4c+AcjDU07Pcz1fQwTX0TQz+Pxqn9i4L1TU3UFpjnrcDgxA== + dependencies: + "@types/node" "*" + xmlbuilder ">=11.0.1" + "@types/pngjs@6.0.4": version "6.0.4" resolved "https://registry.yarnpkg.com/@types/pngjs/-/pngjs-6.0.4.tgz#9a457aebabd944efde1a773a0fa1d74933e8021b" @@ -4817,6 +4805,13 @@ dependencies: "@types/retry" "*" +"@types/promise-retry@^1.1.6": + version "1.1.6" + resolved "https://registry.yarnpkg.com/@types/promise-retry/-/promise-retry-1.1.6.tgz#3c48826d8a27f68f9d4900fc7448f08a1532db44" + integrity sha512-EC1+OMXV0PZb0pf+cmyxc43MEP2CDumZe4AfuxWboxxEixztIebknpJPZAX5XlodGF1OY+C1E/RAeNGzxf+bJA== + dependencies: + "@types/retry" "*" + "@types/prompts@2.4.2": version "2.4.2" resolved "https://registry.yarnpkg.com/@types/prompts/-/prompts-2.4.2.tgz#0785dc09ca79e15ff11b20b7cda2f87af3165a7d" @@ -4825,6 +4820,14 @@ "@types/node" "*" kleur "^3.0.3" +"@types/prompts@^2.4.9": + version "2.4.9" + resolved "https://registry.yarnpkg.com/@types/prompts/-/prompts-2.4.9.tgz#8775a31e40ad227af511aa0d7f19a044ccbd371e" + integrity sha512-qTxFi6Buiu8+50/+3DGIWLHM6QuWsEKugJnnP6iv2Mc4ncxE4A/OJkjuVOA+5X0X1S/nq5VJRa8Lu+nwcvbrKA== + dependencies: + "@types/node" "*" + kleur "^3.0.3" + "@types/qs@*": version "6.9.7" resolved "https://registry.yarnpkg.com/@types/qs/-/qs-6.9.7.tgz#63bb7d067db107cc1e457c303bc25d511febf6cb" @@ -4857,6 +4860,11 @@ resolved "https://registry.yarnpkg.com/@types/retry/-/retry-0.12.2.tgz#ed279a64fa438bb69f2480eda44937912bb7480a" integrity sha512-XISRgDJ2Tc5q4TRqvgJtzsRkFYNJzZrhTdtMoGVBttwzzQJkPnS3WWTFc7kuDRoPtPakl+T+OfdEUjYJj7Jbow== +"@types/retry@^0.12.5": + version "0.12.5" + resolved "https://registry.yarnpkg.com/@types/retry/-/retry-0.12.5.tgz#f090ff4bd8d2e5b940ff270ab39fd5ca1834a07e" + integrity sha512-3xSjTp3v03X/lSQLkczaN9UIEwJMoMCA1+Nb5HfbJEQWogdeQIyVtTvxPXDQjZ5zws8rFQfVfRdz03ARihPJgw== + "@types/semver@7.5.6": version "7.5.6" resolved "https://registry.yarnpkg.com/@types/semver/-/semver-7.5.6.tgz#c65b2bfce1bec346582c07724e3f8c1017a20339" @@ -4867,6 +4875,11 @@ resolved "https://registry.yarnpkg.com/@types/semver/-/semver-7.5.8.tgz#8268a8c57a3e4abd25c165ecd36237db7948a55e" integrity sha512-I8EUhyrgfLrcTkzV3TSsGyl1tSuPrEDzr0yd5m90UgNxQkyDXULk3b6MlQqTCpZpNtWe1K0hzclnZkTcLBe2UQ== +"@types/semver@^7.5.8": + version "7.7.1" + resolved "https://registry.yarnpkg.com/@types/semver/-/semver-7.7.1.tgz#3ce3af1a5524ef327d2da9e4fd8b6d95c8d70528" + integrity sha512-FmgJfu+MOcQ370SD0ev7EI8TlCAfKYU+B4m5T3yXc1CiRN94g/SZPtsCkk506aUDtlMnFZvasDwHHUcZUEaYuA== + "@types/send@*": version "1.2.1" resolved "https://registry.yarnpkg.com/@types/send/-/send-1.2.1.tgz#6a784e45543c18c774c049bff6d3dbaf045c9c74" @@ -4939,6 +4952,11 @@ resolved "https://registry.yarnpkg.com/@types/uuid/-/uuid-9.0.7.tgz#b14cebc75455eeeb160d5fe23c2fcc0c64f724d8" integrity sha512-WUtIVRUZ9i5dYXefDEAI7sh9/O7jGvHg7Df/5O/gtH3Yabe5odI3UWopVR1qbPXQtvOxWu3mM4XxlYeZtMWF4g== +"@types/uuid@^9.0.7", "@types/uuid@^9.0.8": + version "9.0.8" + resolved "https://registry.yarnpkg.com/@types/uuid/-/uuid-9.0.8.tgz#7545ba4fc3c003d6c756f651f3bf163d8f0f29ba" + integrity sha512-jg+97EGIcY9AGHJJRaaPVgetKDsrTgbRjQ5Msgjh/DQKEFl0DtyRr/VCOyD1T2R1MNeWPK/u7JoGhlDZnKBAfA== + "@types/vinyl@^2.0.4": version "2.0.6" resolved "https://registry.yarnpkg.com/@types/vinyl/-/vinyl-2.0.6.tgz#b2d134603557a7c3d2b5d3dc23863ea2b5eb29b0" @@ -5095,6 +5113,11 @@ "@urql/core" ">=4.0.0" wonka "^6.3.2" +"@vercel/ncc@^0.38.1": + version "0.38.4" + resolved "https://registry.yarnpkg.com/@vercel/ncc/-/ncc-0.38.4.tgz#e1fb8be9e7ed33bf44c121131d4c6e95f784afac" + integrity sha512-8LwjnlP39s08C08J5NstzriPvW1SP8Zfpp1BvC2sI35kPeZnHfxVkCwu4/+Wodgnd60UtT1n8K8zw+Mp7J9JmQ== + "@whatwg-node/events@^0.0.3": version "0.0.3" resolved "https://registry.yarnpkg.com/@whatwg-node/events/-/events-0.0.3.tgz#13a65dd4f5893f55280f766e29ae48074927acad" @@ -5357,7 +5380,7 @@ ansi-regex@^6.0.1: resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-6.0.1.tgz#3183e38fae9a65d7cb5e53945cd5897d0260a06a" integrity sha512-n5M855fKb2SsfMIiFFoVrABHJC8QtHwVx+mHWP3QcEqBHYienj5dHSgjbxtC0WEZXYt4wcD6zrQElDPhFuZgfA== -ansi-styles@^3.2.1: +ansi-styles@^3.2.0, ansi-styles@^3.2.1: version "3.2.1" resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-3.2.1.tgz#41fbb20243e50b12be0f04b8dedbf07520ce841d" integrity sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA== @@ -5407,6 +5430,14 @@ anymatch@^3.0.3: normalize-path "^3.0.0" picomatch "^2.0.4" +anymatch@~3.1.2: + version "3.1.3" + resolved "https://registry.yarnpkg.com/anymatch/-/anymatch-3.1.3.tgz#790c58b19ba1720a84205b57c618d5ad8524973e" + integrity sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw== + dependencies: + normalize-path "^3.0.0" + picomatch "^2.0.4" + aproba@2.0.0, "aproba@^1.0.3 || ^2.0.0": version "2.0.0" resolved "https://registry.yarnpkg.com/aproba/-/aproba-2.0.0.tgz#52520b8ae5b569215b354efc0caa3fe1e45a8adc" @@ -5877,6 +5908,11 @@ bin-links@^4.0.4: read-cmd-shim "^4.0.0" write-file-atomic "^5.0.0" +binary-extensions@^2.0.0: + version "2.3.0" + resolved "https://registry.yarnpkg.com/binary-extensions/-/binary-extensions-2.3.0.tgz#f6e14a97858d327252200242d4ccfe522c445522" + integrity sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw== + binaryextensions@^4.15.0, binaryextensions@^4.16.0: version "4.18.0" resolved "https://registry.yarnpkg.com/binaryextensions/-/binaryextensions-4.18.0.tgz#22aeada2d14de062c60e8ca59a504a5636a76ceb" @@ -5891,6 +5927,15 @@ bl@^4.0.3, bl@^4.1.0: inherits "^2.0.4" readable-stream "^3.4.0" +bl@^5.0.0: + version "5.1.0" + resolved "https://registry.yarnpkg.com/bl/-/bl-5.1.0.tgz#183715f678c7188ecef9fe475d90209400624273" + integrity sha512-tv1ZJHLfTDnXE6tMHv73YgSJaWR2AFuPwMntBe7XL/GBFHnT0CLnsHMogfk5+GzCDC5ZWarSCYaIGATZt9dNsQ== + dependencies: + buffer "^6.0.3" + inherits "^2.0.4" + readable-stream "^3.4.0" + body-parser@1.20.3: version "1.20.3" resolved "https://registry.yarnpkg.com/body-parser/-/body-parser-1.20.3.tgz#1953431221c6fb5cd63c4b36d53fab0928e548c6" @@ -5945,7 +5990,7 @@ brace-expansion@^2.0.1: dependencies: balanced-match "^1.0.0" -braces@^3.0.3: +braces@^3.0.3, braces@~3.0.2: version "3.0.3" resolved "https://registry.yarnpkg.com/braces/-/braces-3.0.3.tgz#490332f40919452272d55a8480adc0c441358789" integrity sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA== @@ -5995,7 +6040,7 @@ browserslist@^4.24.0: node-releases "^2.0.27" update-browserslist-db "^1.1.4" -bs-logger@0.x: +bs-logger@0.x, bs-logger@^0.2.6: version "0.2.6" resolved "https://registry.yarnpkg.com/bs-logger/-/bs-logger-0.2.6.tgz#eb7d365307a72cf974cc6cda76b68354ad336bd8" integrity sha512-pd8DCoxmbgc7hyPKOvxtqNcjYoOsABPQdcCUjGp3d42VR2CX1ORhk2A87oqqu5R1kk+76nsxZupkmyd+MVtCog== @@ -6354,6 +6399,11 @@ chalk@^2.0.0, chalk@^2.0.1, chalk@^2.4.2: escape-string-regexp "^1.0.5" supports-color "^5.3.0" +chalk@^5.0.0: + version "5.6.2" + resolved "https://registry.yarnpkg.com/chalk/-/chalk-5.6.2.tgz#b1238b6e23ea337af71c7f8a295db5af0c158aea" + integrity sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA== + chalk@^5.2.0: version "5.3.0" resolved "https://registry.yarnpkg.com/chalk/-/chalk-5.3.0.tgz#67c20a7ebef70e7f3970a01f90fa210cb6860385" @@ -6418,6 +6468,31 @@ check-disk-space@3.4.0: resolved "https://registry.yarnpkg.com/check-disk-space/-/check-disk-space-3.4.0.tgz#eb8e69eee7a378fd12e35281b8123a8b4c4a8ff7" integrity sha512-drVkSqfwA+TvuEhFipiR1OC9boEGZL5RrWvVsOthdcvQNXyCCuKkEiTOTXZ7qxSf/GLwq4GvzfrQD/Wz325hgw== +chokidar-cli@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/chokidar-cli/-/chokidar-cli-3.0.0.tgz#29283666063b9e167559d30f247ff8fc48794eb7" + integrity sha512-xVW+Qeh7z15uZRxHOkP93Ux8A0xbPzwK4GaqD8dQOYc34TlkqUhVSS59fK36DOp5WdJlrRzlYSy02Ht99FjZqQ== + dependencies: + chokidar "^3.5.2" + lodash.debounce "^4.0.8" + lodash.throttle "^4.1.1" + yargs "^13.3.0" + +chokidar@^3.5.2: + version "3.6.0" + resolved "https://registry.yarnpkg.com/chokidar/-/chokidar-3.6.0.tgz#197c6cc669ef2a8dc5e7b4d97ee4e092c3eb0d5b" + integrity sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw== + dependencies: + anymatch "~3.1.2" + braces "~3.0.2" + glob-parent "~5.1.2" + is-binary-path "~2.1.0" + is-glob "~4.0.1" + normalize-path "~3.0.0" + readdirp "~3.6.0" + optionalDependencies: + fsevents "~2.3.2" + chownr@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/chownr/-/chownr-2.0.0.tgz#15bfbe53d2eab4cf70f18a8cd68ebe5b3cb1dece" @@ -6469,6 +6544,13 @@ cli-cursor@^2.1.0: dependencies: restore-cursor "^2.0.0" +cli-cursor@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/cli-cursor/-/cli-cursor-4.0.0.tgz#3cecfe3734bf4fe02a8361cbdc0f6fe28c6a57ea" + integrity sha512-VGtlMu3x/4DOtIUwEkRezxUZ2lBacNJCHash0N0WeZDBS+7Ux1dm3XWAgWYxLJFMMdOeXMHXorshEFhbMSGelg== + dependencies: + restore-cursor "^4.0.0" + cli-progress@3.12.0, cli-progress@^3.10.0, cli-progress@^3.12.0: version "3.12.0" resolved "https://registry.yarnpkg.com/cli-progress/-/cli-progress-3.12.0.tgz#807ee14b66bcc086258e444ad0f19e7d42577942" @@ -6481,7 +6563,7 @@ cli-spinners@2.6.1, cli-spinners@^2.5.0: resolved "https://registry.yarnpkg.com/cli-spinners/-/cli-spinners-2.6.1.tgz#adc954ebe281c37a6319bfa401e6dd2488ffb70d" integrity sha512-x/5fWmGMnbKQAaNwN+UZlV79qBLM9JFnJuJ03gIi5whrob0xV0ofNVHy9DhwGdsMJQc2OKv0oGmLzvaqvAVv+g== -cli-spinners@^2.0.0: +cli-spinners@^2.0.0, cli-spinners@^2.6.1: version "2.9.2" resolved "https://registry.yarnpkg.com/cli-spinners/-/cli-spinners-2.9.2.tgz#1773a8f4b9c4d6ac31563df53b3fc1d79462fe41" integrity sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg== @@ -6511,6 +6593,15 @@ cli-width@^3.0.0: resolved "https://registry.yarnpkg.com/cli-width/-/cli-width-3.0.0.tgz#a2f48437a2caa9a22436e794bf071ec9e61cedf6" integrity sha512-FxqpkPPwu1HjuN93Omfm4h8uIanXofW0RxVEW3k5RKx+mJJYSthzNhp32Kzxxy3YAEZ/Dc/EWN1vZRY0+kOhbw== +cliui@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/cliui/-/cliui-5.0.0.tgz#deefcfdb2e800784aa34f46fa08e06851c7bbbc5" + integrity sha512-PYeGSEmmHM6zvoef2w8TPzlrnNpXIjTipYK780YswmIP9vjxmd6Y2a3CB2Ks6/AU8NHjZugXvo8w3oWM2qnwXA== + dependencies: + string-width "^3.1.0" + strip-ansi "^5.2.0" + wrap-ansi "^5.1.0" + cliui@^6.0.0: version "6.0.0" resolved "https://registry.yarnpkg.com/cliui/-/cliui-6.0.0.tgz#511d702c0c4e41ca156d7d0e96021f23e13225b1" @@ -6675,6 +6766,11 @@ commander@7.1.0: resolved "https://registry.yarnpkg.com/commander/-/commander-7.1.0.tgz#f2eaecf131f10e36e07d894698226e36ae0eb5ff" integrity sha512-pRxBna3MJe6HKnBGsDyMv8ETbptw3axEdYHoqNh7gu5oDcew8fs0xnivZGm06Ogk8zGAJ9VX+OPEr2GXEQK4dg== +commander@^13.1.0: + version "13.1.0" + resolved "https://registry.yarnpkg.com/commander/-/commander-13.1.0.tgz#776167db68c78f38dcce1f9b8d7b8b9a488abf46" + integrity sha512-/rFeCpNJQbhSZjGVwO9RFV3xPqbnERS8MmIQzCtD/zl6gpJuV/bMLuN92oG3F7d8oDEHHRrujSXNUr8fpjntKw== + commander@^4.0.0: version "4.1.1" resolved "https://registry.yarnpkg.com/commander/-/commander-4.1.1.tgz#9fd602bd936294e9e9ef46a3f4d6964044b18068" @@ -7444,6 +7540,11 @@ emittery@^0.13.1: resolved "https://registry.yarnpkg.com/emittery/-/emittery-0.13.1.tgz#c04b8c3457490e0847ae51fced3af52d338e3dad" integrity sha512-DeWwawk6r5yR9jFgnDKYt4sLS0LmHJJi3ZOnb5/JdbYwj3nW+FxQnHIjhBKz8YLC7oRNPVM9NQ47I3CVx34eqQ== +emoji-regex@^7.0.1: + version "7.0.3" + resolved "https://registry.yarnpkg.com/emoji-regex/-/emoji-regex-7.0.3.tgz#933a04052860c85e83c122479c4748a8e4c72156" + integrity sha512-CwBLREIQ7LvYFB0WyRvwhq5N5qPhc6PMjD6bYggFlI5YyDgl+0vxq5VHbMOFqLg7hfWzmu8T5Z1QofhmTIhItA== + emoji-regex@^8.0.0: version "8.0.0" resolved "https://registry.yarnpkg.com/emoji-regex/-/emoji-regex-8.0.0.tgz#e818fd69ce5ccfcb404594f842963bf53164cc37" @@ -7895,7 +7996,7 @@ eslint-module-utils@^2.8.0: dependencies: debug "^3.2.7" -eslint-plugin-async-protect@3.1.0: +eslint-plugin-async-protect@3.1.0, eslint-plugin-async-protect@^3.1.0: version "3.1.0" resolved "https://registry.yarnpkg.com/eslint-plugin-async-protect/-/eslint-plugin-async-protect-3.1.0.tgz#6ac0fc43edc939ceaf7b3d99aebd193d1edd973c" integrity sha512-Tkc/mHNsAaZO1ejIFw+Qr2WS4xlvQtQ51KTBscSCpfJ4fR3X9HXTTmFt6/vHLTGXRoPrJHKDdOGFoPR0weq9VA== @@ -8488,6 +8589,13 @@ find-up@^2.0.0: dependencies: locate-path "^2.0.0" +find-up@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/find-up/-/find-up-3.0.0.tgz#49169f1d7993430646da61ecc5ae355c21c97b73" + integrity sha512-1yD6RmLI1XBfxugvORwlck6f75tYL+iR0jqwsOrOxMZyGYqUuDhJ0l4AXdO1iX/FTs9cBAMEk1gWSEx1kSbylg== + dependencies: + locate-path "^3.0.0" + find-up@^4.0.0, find-up@^4.1.0: version "4.1.0" resolved "https://registry.yarnpkg.com/find-up/-/find-up-4.1.0.tgz#97afe7d6cdc0bc5928584b7c8d7b16e8a9aa5d19" @@ -8715,6 +8823,11 @@ fsevents@^2.3.2: resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-2.3.2.tgz#8a526f78b8fdf4623b709e0b975c52c24c02fd1a" integrity sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA== +fsevents@~2.3.2: + version "2.3.3" + resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-2.3.3.tgz#cac6407785d03675a2a5e1a5305c697b347d90d6" + integrity sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw== + function-bind@^1.1.1: version "1.1.1" resolved "https://registry.yarnpkg.com/function-bind/-/function-bind-1.1.1.tgz#a56899d3ea3c9bab874bb9773b7c5ede92f4895d" @@ -8998,13 +9111,18 @@ glob-parent@6.0.2, glob-parent@^6.0.2: dependencies: is-glob "^4.0.3" -glob-parent@^5.1.0, glob-parent@^5.1.2: +glob-parent@^5.1.0, glob-parent@^5.1.2, glob-parent@~5.1.2: version "5.1.2" resolved "https://registry.yarnpkg.com/glob-parent/-/glob-parent-5.1.2.tgz#869832c58034fe68a4093c17dc15e8340d8401c4" integrity sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow== dependencies: is-glob "^4.0.1" +glob-to-regex.js@^1.0.1: + version "1.2.0" + resolved "https://registry.yarnpkg.com/glob-to-regex.js/-/glob-to-regex.js-1.2.0.tgz#2b323728271d133830850e32311f40766c5f6413" + integrity sha512-QMwlOQKU/IzqMUOAZWubUOT8Qft+Y0KQWnX9nK3ch0CJg0tTp4TvGZsTfudYKv2NzoQSyPcnA6TYeIQ3jGichQ== + glob@7.1.6: version "7.1.6" resolved "https://registry.yarnpkg.com/glob/-/glob-7.1.6.tgz#141f33b81a7c2492e125594307480c46679278a6" @@ -9339,7 +9457,7 @@ gtoken@^7.0.0: gaxios "^6.0.0" jws "^4.0.0" -handlebars@^4.7.7: +handlebars@^4.7.7, handlebars@^4.7.8: version "4.7.8" resolved "https://registry.yarnpkg.com/handlebars/-/handlebars-4.7.8.tgz#41c42c18b1be2365439188c77c6afae71c0cd9e9" integrity sha512-vafaFqs8MZkRrSX7sFVUdo3ap/eNiLnb4IakshzvP56X5Nr1iGKAIqdX6tMlm6HcNRIkr6AxO5jFEoJzzpT8aQ== @@ -9640,6 +9758,11 @@ humanize-ms@^1.2.1: dependencies: ms "^2.0.0" +hyperdyperid@^1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/hyperdyperid/-/hyperdyperid-1.2.0.tgz#59668d323ada92228d2a869d3e474d5a33b69e6b" + integrity sha512-Y93lCzHYgGWdrJ66yIktxiaGULYc6oGiABxhcO5AufBeOyoIdZF7bIfLaOrbM0iGIOXQQgxxRrFEnb+Y6w1n4A== + hyperlinker@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/hyperlinker/-/hyperlinker-1.0.0.tgz#23dc9e38a206b208ee49bc2d6c8ef47027df0c0e" @@ -9797,7 +9920,7 @@ ini@^1.3.2: resolved "https://registry.yarnpkg.com/ini/-/ini-1.3.5.tgz#eee25f56db1c9ec6085e0c22778083f596abf927" integrity sha512-RZY5huIKCMRWDUqZlEi72f/lmXKMvuszcMBduliQ3nnWbx9X/ZBQO7DijMEYS9EhHBb2qacRUMtC7svLwe0lcw== -ini@^1.3.8: +ini@^1.3.8, ini@~1.3.0: version "1.3.8" resolved "https://registry.yarnpkg.com/ini/-/ini-1.3.8.tgz#a29da425b48806f34767a4efce397269af28432c" integrity sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew== @@ -9973,6 +10096,13 @@ is-bigint@^1.0.1: resolved "https://registry.yarnpkg.com/is-bigint/-/is-bigint-1.0.2.tgz#ffb381442503235ad245ea89e45b3dbff040ee5a" integrity sha512-0JV5+SOCQkIdzjBK9buARcV804Ddu7A0Qet6sHi3FimE9ne6m4BGQZfRn+NZiXbBk4F4XmHfDZIipLj9pX8dSA== +is-binary-path@~2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/is-binary-path/-/is-binary-path-2.1.0.tgz#ea1f7f3b80f064236e83470f86c09c254fb45b09" + integrity sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw== + dependencies: + binary-extensions "^2.0.0" + is-boolean-object@^1.1.0: version "1.1.1" resolved "https://registry.yarnpkg.com/is-boolean-object/-/is-boolean-object-1.1.1.tgz#3c0878f035cb821228d350d2e1e36719716a3de8" @@ -10081,6 +10211,11 @@ is-finalizationregistry@^1.0.2: dependencies: call-bind "^1.0.2" +is-fullwidth-code-point@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/is-fullwidth-code-point/-/is-fullwidth-code-point-2.0.0.tgz#a3b30a5c4f199183167aaab93beefae3ddfb654f" + integrity sha512-VHskAKYM8RfSFXwee5t5cbN5PZeq1Wrh6qd5bkyiXIf6UQcN6w/A0eXM9r6t8d+GYOh+o6ZhiEnb88LN/Y8m2w== + is-fullwidth-code-point@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz#f116f8064fe90b3f7844a38997c0b75051269f1d" @@ -10105,7 +10240,7 @@ is-glob@4.0.1, is-glob@^4.0.0, is-glob@^4.0.1: dependencies: is-extglob "^2.1.1" -is-glob@4.0.3, is-glob@^4.0.3: +is-glob@4.0.3, is-glob@^4.0.3, is-glob@~4.0.1: version "4.0.3" resolved "https://registry.yarnpkg.com/is-glob/-/is-glob-4.0.3.tgz#64f61e42cbbb2eec2071a9dac0b28ba1e65d5084" integrity sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg== @@ -10117,6 +10252,11 @@ is-interactive@^1.0.0: resolved "https://registry.yarnpkg.com/is-interactive/-/is-interactive-1.0.0.tgz#cea6e6ae5c870a7b0a0004070b7b587e0252912e" integrity sha512-2HvIEKRoqS62guEC+qBjpvRubdX910WCMuJTZ+I9yvqKU2/12eSL549HMwtabb4oupdj2sMP50k+XJfB/8JE6w== +is-interactive@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/is-interactive/-/is-interactive-2.0.0.tgz#40c57614593826da1100ade6059778d597f16e90" + integrity sha512-qP1vozQRI+BMOPcjFzrjXuQvdak2pHNUMZoeG2eRbiSqyvbEf/wQtEOTOX1guk6E3t36RkaqiSt8A/6YElNxLQ== + is-lambda@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/is-lambda/-/is-lambda-1.0.1.tgz#3d9877899e6a53efc0160504cde15f82e6f061d5" @@ -10352,6 +10492,11 @@ is-unicode-supported@^0.1.0: resolved "https://registry.yarnpkg.com/is-unicode-supported/-/is-unicode-supported-0.1.0.tgz#3f26c76a809593b52bfa2ecb5710ed2779b522a7" integrity sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw== +is-unicode-supported@^1.1.0: + version "1.3.0" + resolved "https://registry.yarnpkg.com/is-unicode-supported/-/is-unicode-supported-1.3.0.tgz#d824984b616c292a2e198207d4a609983842f714" + integrity sha512-43r2mRvz+8JRIKnWJ+3j8JtjRKZ6GmjzfaE/qiBJnikNnYv/6bagRJ1kUhNk8R5EX/GkobD+r+sfxCPJsiKBLQ== + is-upper-case@^2.0.2: version "2.0.2" resolved "https://registry.yarnpkg.com/is-upper-case/-/is-upper-case-2.0.2.tgz#f1105ced1fe4de906a5f39553e7d3803fd804649" @@ -10975,7 +11120,7 @@ jest-worker@^29.7.0: merge-stream "^2.0.0" supports-color "^8.0.0" -jest@29.7.0: +jest@29.7.0, jest@^29.7.0: version "29.7.0" resolved "https://registry.yarnpkg.com/jest/-/jest-29.7.0.tgz#994676fc24177f088f1c5e3737f5697204ff2613" integrity sha512-NIy3oAFp9shda19hy4HK0HRTWKtPJmGdnvywu01nOqNC2vZg+Z+fvJDxpMQA88eb2I9EcafcdjYgsDthnYTvGw== @@ -11558,6 +11703,14 @@ locate-path@^2.0.0: p-locate "^2.0.0" path-exists "^3.0.0" +locate-path@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/locate-path/-/locate-path-3.0.0.tgz#dbec3b3ab759758071b58fe59fc41871af21400e" + integrity sha512-7AO748wWnIhNqAuaty2ZWHkQHRSNfPVIsPIfwEOWO22AmaoVrWavlOcMR5nzTLNYvp36X220/maaRsrec1G65A== + dependencies: + p-locate "^3.0.0" + path-exists "^3.0.0" + locate-path@^5.0.0: version "5.0.0" resolved "https://registry.yarnpkg.com/locate-path/-/locate-path-5.0.0.tgz#1afba396afd676a6d42504d0a67a3a7eb9f62aa0" @@ -11582,6 +11735,11 @@ lodash.clonedeep@^4.5.0: resolved "https://registry.yarnpkg.com/lodash.clonedeep/-/lodash.clonedeep-4.5.0.tgz#e23f3f9c4f8fbdde872529c1071857a086e5ccef" integrity sha512-H5ZhCF25riFd9uB5UCkVKo61m3S/xZk1x4wA6yp/L3RFP6Z/eHH1ymQcGLo7J3GMPfm0V/7m1tryHuGVxpqEBQ== +lodash.debounce@^4.0.8: + version "4.0.8" + resolved "https://registry.yarnpkg.com/lodash.debounce/-/lodash.debounce-4.0.8.tgz#82d79bff30a67c4005ffd5e2515300ad9ca4d7af" + integrity sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow== + lodash.flatten@^4.4.0: version "4.4.0" resolved "https://registry.yarnpkg.com/lodash.flatten/-/lodash.flatten-4.4.0.tgz#f31c22225a9632d2bbf8e4addbef240aa765a61f" @@ -11597,7 +11755,7 @@ lodash.ismatch@^4.4.0: resolved "https://registry.yarnpkg.com/lodash.ismatch/-/lodash.ismatch-4.4.0.tgz#756cb5150ca3ba6f11085a78849645f188f85f37" integrity sha1-dWy1FQyjum8RCFp4hJZF8Yj4Xzc= -lodash.memoize@4.x: +lodash.memoize@4.x, lodash.memoize@^4.1.2: version "4.1.2" resolved "https://registry.yarnpkg.com/lodash.memoize/-/lodash.memoize-4.1.2.tgz#bcc6c49a42a2840ed997f323eada5ecd182e0bfe" integrity sha1-vMbEmkKihA7Zl/Mj6tpezRguC/4= @@ -11622,6 +11780,11 @@ lodash.templatesettings@^4.0.0: dependencies: lodash._reinterpolate "^3.0.0" +lodash.throttle@^4.1.1: + version "4.1.1" + resolved "https://registry.yarnpkg.com/lodash.throttle/-/lodash.throttle-4.1.1.tgz#c23e91b710242ac70c37f1e1cda9274cc39bf2f4" + integrity sha512-wIkUCfVKpVsWo3JSZlc+8MB5it+2AN5W8J7YVMST30UrvcQNZ1Okbj+rbVniijTWE6FGYy4XJq/rHkas8qJMLQ== + lodash.without@^4.4.0: version "4.4.0" resolved "https://registry.yarnpkg.com/lodash.without/-/lodash.without-4.4.0.tgz#3cd4574a00b67bae373a94b748772640507b7aac" @@ -11654,6 +11817,14 @@ log-symbols@^4.0.0: dependencies: chalk "^4.0.0" +log-symbols@^5.1.0: + version "5.1.0" + resolved "https://registry.yarnpkg.com/log-symbols/-/log-symbols-5.1.0.tgz#a20e3b9a5f53fac6aeb8e2bb22c07cf2c8f16d93" + integrity sha512-l0x2DvrW294C9uDCoQe1VSU4gf529FkSZ6leBl4TiqZH/e+0R7hSfHQBNut2mNygDgHwvYHfFLn6Oxb3VWj2rA== + dependencies: + chalk "^5.0.0" + is-unicode-supported "^1.1.0" + log-update@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/log-update/-/log-update-4.0.0.tgz#589ecd352471f2a1c0c570287543a64dfd20e0a1" @@ -11753,7 +11924,7 @@ make-dir@^3.0.0: dependencies: semver "^6.0.0" -make-error@1.x, make-error@^1, make-error@^1.1.1: +make-error@1.x, make-error@^1, make-error@^1.1.1, make-error@^1.3.6: version "1.3.6" resolved "https://registry.yarnpkg.com/make-error/-/make-error-1.3.6.tgz#2eb2e37ea9b67c4891f684a1394799af484cf7a2" integrity sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw== @@ -11938,6 +12109,18 @@ memfs@3.4.13: dependencies: fs-monkey "^1.0.3" +memfs@^4.17.1: + version "4.51.1" + resolved "https://registry.yarnpkg.com/memfs/-/memfs-4.51.1.tgz#25945de4a90d1573945105e187daa9385e1bca73" + integrity sha512-Eyt3XrufitN2ZL9c/uIRMyDwXanLI88h/L3MoWqNY747ha3dMR9dWqp8cRT5ntjZ0U1TNuq4U91ZXK0sMBjYOQ== + dependencies: + "@jsonjoy.com/json-pack" "^1.11.0" + "@jsonjoy.com/util" "^1.9.0" + glob-to-regex.js "^1.0.1" + thingies "^2.5.0" + tree-dump "^1.0.3" + tslib "^2.0.0" + meow@^8.1.2: version "8.1.2" resolved "https://registry.yarnpkg.com/meow/-/meow-8.1.2.tgz#bcbe45bda0ee1729d350c03cffc8395a36c4e897" @@ -12628,7 +12811,7 @@ normalize-path@^2.1.1: dependencies: remove-trailing-separator "^1.0.1" -normalize-path@^3.0.0: +normalize-path@^3.0.0, normalize-path@~3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/normalize-path/-/normalize-path-3.0.0.tgz#0dcd69ff23a1c9b11fd0978316644a0388216a65" integrity sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA== @@ -13141,6 +13324,21 @@ ora@^5.4.1: strip-ansi "^6.0.0" wcwidth "^1.0.1" +ora@^6.3.1: + version "6.3.1" + resolved "https://registry.yarnpkg.com/ora/-/ora-6.3.1.tgz#a4e9e5c2cf5ee73c259e8b410273e706a2ad3ed6" + integrity sha512-ERAyNnZOfqM+Ao3RAvIXkYh5joP220yf59gVe2X/cI6SiCxIdi4c9HZKZD8R6q/RDXEje1THBju6iExiSsgJaQ== + dependencies: + chalk "^5.0.0" + cli-cursor "^4.0.0" + cli-spinners "^2.6.1" + is-interactive "^2.0.0" + is-unicode-supported "^1.1.0" + log-symbols "^5.1.0" + stdin-discarder "^0.1.0" + strip-ansi "^7.0.1" + wcwidth "^1.0.1" + os-tmpdir@~1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/os-tmpdir/-/os-tmpdir-1.0.2.tgz#bbe67406c79aa85c5cfec766fe5734555dfa1274" @@ -13177,7 +13375,7 @@ p-limit@^1.1.0: dependencies: p-try "^1.0.0" -p-limit@^2.2.0: +p-limit@^2.0.0, p-limit@^2.2.0: version "2.3.0" resolved "https://registry.yarnpkg.com/p-limit/-/p-limit-2.3.0.tgz#3dd33c647a214fdfffd835933eb086da0dc21db1" integrity sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w== @@ -13191,6 +13389,13 @@ p-locate@^2.0.0: dependencies: p-limit "^1.1.0" +p-locate@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/p-locate/-/p-locate-3.0.0.tgz#322d69a05c0264b25997d9f40cd8a891ab0064a4" + integrity sha512-x+12w/To+4GFfgJhBEpiDcLozRJGegY+Ei7/z0tSLkMmxGZNybVMSfWj9aJn8Z5Fc7dBUNJOOVgPv2H7IwulSQ== + dependencies: + p-limit "^2.0.0" + p-locate@^4.1.0: version "4.1.0" resolved "https://registry.yarnpkg.com/p-locate/-/p-locate-4.1.0.tgz#a3428bb7088b3a60292f66919278b7c297ad4f07" @@ -13781,7 +13986,7 @@ promise@^7.1.1: dependencies: asap "~2.0.3" -prompts@2.4.2: +prompts@2.4.2, prompts@^2.4.2: version "2.4.2" resolved "https://registry.yarnpkg.com/prompts/-/prompts-2.4.2.tgz#7b57e73b3a48029ad10ebd44f74b01722a4cb069" integrity sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q== @@ -13947,6 +14152,16 @@ raw-body@^2.3.3: iconv-lite "~0.4.24" unpipe "~1.0.0" +rc@^1.0.1, rc@^1.1.6: + version "1.2.8" + resolved "https://registry.yarnpkg.com/rc/-/rc-1.2.8.tgz#cd924bf5200a075b83c188cd6b9e211b7fc0d3ed" + integrity sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw== + dependencies: + deep-extend "^0.6.0" + ini "~1.3.0" + minimist "^1.2.0" + strip-json-comments "~2.0.1" + react-is@^16.13.1: version "16.13.1" resolved "https://registry.yarnpkg.com/react-is/-/react-is-16.13.1.tgz#789729a4dc36de2999dc156dd6c1d9c18cea56a4" @@ -14086,6 +14301,13 @@ readdir-scoped-modules@^1.1.0: graceful-fs "^4.1.2" once "^1.3.0" +readdirp@~3.6.0: + version "3.6.0" + resolved "https://registry.yarnpkg.com/readdirp/-/readdirp-3.6.0.tgz#74a370bd857116e245b29cc97340cd431a02a6c7" + integrity sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA== + dependencies: + picomatch "^2.2.1" + rechoir@^0.6.2: version "0.6.2" resolved "https://registry.yarnpkg.com/rechoir/-/rechoir-0.6.2.tgz#85204b54dba82d5742e28c96756ef43af50e3384" @@ -14158,6 +14380,21 @@ regexpp@^3.0.0: resolved "https://registry.yarnpkg.com/regexpp/-/regexpp-3.2.0.tgz#0425a2768d8f23bad70ca4b90461fa2f1213e1b2" integrity sha512-pq2bWo9mVD43nbts2wGv17XLiNLya+GklZ8kaDLV2Z08gDCsGpnKn9BFMepvWuHCbyVvY7J5o5+BVvoQbmlJLg== +registry-auth-token@3.3.2: + version "3.3.2" + resolved "https://registry.yarnpkg.com/registry-auth-token/-/registry-auth-token-3.3.2.tgz#851fd49038eecb586911115af845260eec983f20" + integrity sha512-JL39c60XlzCVgNrO+qq68FoNb56w/m7JYvGR2jT5iR1xBrUA3Mfx5Twk5rqTThPmQKMWydGmq8oFtDlxfrmxnQ== + dependencies: + rc "^1.1.6" + safe-buffer "^5.0.1" + +registry-url@3.1.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/registry-url/-/registry-url-3.1.0.tgz#3d4ef870f73dde1d77f0cf9a381432444e174942" + integrity sha512-ZbgR5aZEdf4UKZVBPYIgaglBmSF2Hi94s2PcIHhRGFjKYu+chjJdYfHn4rt3hB6eCKLJ8giVIIfgMa1ehDfZKA== + dependencies: + rc "^1.0.1" + relay-runtime@12.0.0: version "12.0.0" resolved "https://registry.yarnpkg.com/relay-runtime/-/relay-runtime-12.0.0.tgz#1e039282bdb5e0c1b9a7dc7f6b9a09d4f4ff8237" @@ -14300,6 +14537,14 @@ restore-cursor@^3.1.0: onetime "^5.1.0" signal-exit "^3.0.2" +restore-cursor@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/restore-cursor/-/restore-cursor-4.0.0.tgz#519560a4318975096def6e609d44100edaa4ccb9" + integrity sha512-I9fPXU9geO9bHOt9pHHOhOkYerIMsmVaWB0rA2AI9ERh/+x/i7MV5HKBNrg+ljO5eoPVgCcnFuRjJ9uH6I/3eg== + dependencies: + onetime "^5.1.0" + signal-exit "^3.0.2" + retry-request@^7.0.0: version "7.0.2" resolved "https://registry.yarnpkg.com/retry-request/-/retry-request-7.0.2.tgz#60bf48cfb424ec01b03fca6665dee91d06dd95f3" @@ -14500,6 +14745,11 @@ semver@^7.6.2: resolved "https://registry.yarnpkg.com/semver/-/semver-7.6.2.tgz#1e3b34759f896e8f14d6134732ce798aeb0c6e13" integrity sha512-FNAIBWCx9qcRhoHcgcJ0gvU7SN1lYU2ZXuSfl04bSC5OpvDHFyJCjdNHomPXxjQlCBU67YW64PzY7/VIEH7F2w== +semver@^7.7.3: + version "7.7.3" + resolved "https://registry.yarnpkg.com/semver/-/semver-7.7.3.tgz#4b5f4143d007633a8dc671cd0a6ef9147b8bb946" + integrity sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q== + send@0.18.0: version "0.18.0" resolved "https://registry.yarnpkg.com/send/-/send-0.18.0.tgz#670167cc654b05f5aa4a767f9113bb371bc706be" @@ -15038,6 +15288,13 @@ statuses@~2.0.2: resolved "https://registry.yarnpkg.com/statuses/-/statuses-2.0.2.tgz#8f75eecef765b5e1cfcdc080da59409ed424e382" integrity sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw== +stdin-discarder@^0.1.0: + version "0.1.0" + resolved "https://registry.yarnpkg.com/stdin-discarder/-/stdin-discarder-0.1.0.tgz#22b3e400393a8e28ebf53f9958f3880622efde21" + integrity sha512-xhV7w8S+bUwlPTb4bAOUQhv8/cSS5offJuX8GQGq32ONF0ZtDWKfkdomM3HMRA+LhX6um/FZ0COqlwsjD53LeQ== + dependencies: + bl "^5.0.0" + stream-buffers@2.2.x: version "2.2.0" resolved "https://registry.yarnpkg.com/stream-buffers/-/stream-buffers-2.2.0.tgz#91d5f5130d1cef96dcfa7f726945188741d09ee4" @@ -15110,6 +15367,15 @@ string-length@^5.0.1: is-fullwidth-code-point "^3.0.0" strip-ansi "^6.0.1" +string-width@^3.0.0, string-width@^3.1.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/string-width/-/string-width-3.1.0.tgz#22767be21b62af1081574306f69ac51b62203961" + integrity sha512-vafcv6KjVZKSgz06oM/H6GDBrAtz8vdhQakGjFIvNrHA6y3HCF1CInLy+QLq8dTJPQ1b+KDUqDFctkdRW44e1w== + dependencies: + emoji-regex "^7.0.1" + is-fullwidth-code-point "^2.0.0" + strip-ansi "^5.1.0" + string-width@^4.0.0, string-width@^4.1.0, string-width@^4.2.0: version "4.2.0" resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.0.tgz#952182c46cc7b2c313d1596e623992bd163b72b5" @@ -15264,7 +15530,7 @@ string_decoder@~1.1.1: dependencies: ansi-regex "^5.0.1" -strip-ansi@^5.2.0: +strip-ansi@^5.0.0, strip-ansi@^5.1.0, strip-ansi@^5.2.0: version "5.2.0" resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-5.2.0.tgz#8c9a536feb6afc962bdfa5b104a5091c1ad9c0ae" integrity sha512-DuRs1gKbBqsMKIZlrffwlug8MHkcnpjs5VPmL1PAh+mA30U0DTotfDZ0d2UUsXpPmPmMMJ6W773MaA3J+lbiWA== @@ -15341,6 +15607,11 @@ strip-json-comments@^3.1.1: resolved "https://registry.yarnpkg.com/strip-json-comments/-/strip-json-comments-3.1.1.tgz#31f1281b3832630434831c310c01cccda8cbe006" integrity sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig== +strip-json-comments@~2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/strip-json-comments/-/strip-json-comments-2.0.1.tgz#3c531942e908c2697c0ec344858c286c7ca0a60a" + integrity sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ== + strnum@^1.1.1: version "1.1.2" resolved "https://registry.yarnpkg.com/strnum/-/strnum-1.1.2.tgz#57bca4fbaa6f271081715dbc9ed7cee5493e28e4" @@ -15482,7 +15753,7 @@ tar@6.2.1, tar@^6.0.2, tar@^6.1.0, tar@^6.1.11, tar@^6.1.2, tar@^6.2.1: mkdirp "^1.0.3" yallist "^4.0.0" -tar@^7.4.3: +tar@^7.2.0, tar@^7.4.3: version "7.5.2" resolved "https://registry.yarnpkg.com/tar/-/tar-7.5.2.tgz#115c061495ec51ff3c6745ff8f6d0871c5b1dedc" integrity sha512-7NyxrTE4Anh8km8iEy7o0QYPs+0JKBTj5ZaqHg6B39erLg0qYXN3BijtShwbsNSvQ+LN75+KV+C4QR/f6Gwnpg== @@ -15567,6 +15838,11 @@ thenify-all@^1.0.0: dependencies: any-promise "^1.0.0" +thingies@^2.5.0: + version "2.5.0" + resolved "https://registry.yarnpkg.com/thingies/-/thingies-2.5.0.tgz#5f7b882c933b85989f8466b528a6247a6881e04f" + integrity sha512-s+2Bwztg6PhWUD7XMfeYm5qliDdSiZm7M7n8KjTkIsm3l/2lgVRc2/Gx/v+ZX8lT4FMA+i8aQvhcWylldc+ZNw== + this-file@^2.0.3: version "2.0.3" resolved "https://registry.yarnpkg.com/this-file/-/this-file-2.0.3.tgz#13bd2bcfbab2ce86a37a15689df1d14861032b1b" @@ -15641,6 +15917,11 @@ tr46@~0.0.3: resolved "https://registry.yarnpkg.com/tr46/-/tr46-0.0.3.tgz#8184fd347dac9cdc185992f3a6622e14b9d9ab6a" integrity sha1-gYT9NH2snNwYWZLzpmIuFLnZq2o= +tree-dump@^1.0.3, tree-dump@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/tree-dump/-/tree-dump-1.1.0.tgz#ab29129169dc46004414f5a9d4a3c6e89f13e8a4" + integrity sha512-rMuvhU4MCDbcbnleZTFezWsaZXRFemSqAM+7jPnzUl1fo9w3YEKOxAeui0fz3OI4EU4hf23iyA7uQRVko+UaBA== + tree-kill@^1.2.2: version "1.2.2" resolved "https://registry.yarnpkg.com/tree-kill/-/tree-kill-1.2.2.tgz#4ca09a9092c88b73a7cdc5e8a01b507b0790a0cc" @@ -15704,19 +15985,34 @@ ts-jest@29.1.4: semver "^7.5.3" yargs-parser "^21.0.1" +ts-jest@^29.1.4: + version "29.4.6" + resolved "https://registry.yarnpkg.com/ts-jest/-/ts-jest-29.4.6.tgz#51cb7c133f227396818b71297ad7409bb77106e9" + integrity sha512-fSpWtOO/1AjSNQguk43hb/JCo16oJDnMJf3CdEGNkqsEX3t0KX96xvyX1D7PfLCpVoKu4MfVrqUkFyblYoY4lA== + dependencies: + bs-logger "^0.2.6" + fast-json-stable-stringify "^2.1.0" + handlebars "^4.7.8" + json5 "^2.2.3" + lodash.memoize "^4.1.2" + make-error "^1.3.6" + semver "^7.7.3" + type-fest "^4.41.0" + yargs-parser "^21.1.1" + ts-log@^2.2.3: version "2.2.3" resolved "https://registry.yarnpkg.com/ts-log/-/ts-log-2.2.3.tgz#4da5640fe25a9fb52642cd32391c886721318efb" integrity sha512-XvB+OdKSJ708Dmf9ore4Uf/q62AYDTzFcAdxc8KNML1mmAWywRFVt/dn1KYJH8Agt5UJNujfM3znU5PxgAzA2w== -ts-mockito@2.6.1: +ts-mockito@2.6.1, ts-mockito@^2.6.1: version "2.6.1" resolved "https://registry.yarnpkg.com/ts-mockito/-/ts-mockito-2.6.1.tgz#bc9ee2619033934e6fad1c4455aca5b5ace34e73" integrity sha512-qU9m/oEBQrKq5hwfbJ7MgmVN5Gu6lFnIGWvpxSjrqq6YYEVv+RwVFWySbZMBgazsWqv6ctAyVBpo9TmAxnOEKw== dependencies: lodash "^4.17.5" -ts-node@10.9.2: +ts-node@10.9.2, ts-node@^10.9.2: version "10.9.2" resolved "https://registry.yarnpkg.com/ts-node/-/ts-node-10.9.2.tgz#70f021c9e185bccdca820e26dc413805c101c71f" integrity sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ== @@ -15814,6 +16110,11 @@ tslib@^2.3.0, tslib@^2.4.0: resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.4.0.tgz#7cecaa7f073ce680a05847aa77be941098f36dc3" integrity sha512-d6xOpEDfsi2CZVlPQzGeux8XMwLT9hssAsaPYExaQMuYskwb+x1x7J371tWlbBdWHroy99KnVB6qIkUbs5X3UQ== +tslib@^2.6.3: + version "2.8.1" + resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.8.1.tgz#612efe4ed235d567e8aba5f2a5fab70280ade83f" + integrity sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w== + tslib@~2.5.0: version "2.5.3" resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.5.3.tgz#24944ba2d990940e6e982c4bea147aba80209913" @@ -15908,6 +16209,11 @@ type-fest@^3.0.0: resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-3.5.1.tgz#9555ae435f560c1b4447b70bdd195bb2c86c6c92" integrity sha512-70T99cpILFk2fzwuljwWxmazSphFrdOe3gRHbp6bqs71pxFBbJwFqnmkLO2lQL6aLHxHmYAnP/sL+AJWpT70jA== +type-fest@^4.41.0: + version "4.41.0" + resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-4.41.0.tgz#6ae1c8e5731273c2bf1f58ad39cbae2c91a46c58" + integrity sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA== + type-is@^1.6.16, type-is@~1.6.18: version "1.6.18" resolved "https://registry.yarnpkg.com/type-is/-/type-is-1.6.18.tgz#4e552cd05df09467dcbc4ef739de89f2cf37c131" @@ -16041,6 +16347,11 @@ typescript@5.4.5: resolved "https://registry.yarnpkg.com/typescript/-/typescript-5.4.5.tgz#42ccef2c571fdbd0f6718b1d1f5e6e5ef006f611" integrity sha512-vcI4UpRgg81oIRUFwR0WSIHKt11nJ7SAVlYNIu+QpqeyXP+gpQJy/Z4+F0aGxSE4MqwjyXvW/TzgkLAx2AGHwQ== +typescript@^5.5.4: + version "5.9.3" + resolved "https://registry.yarnpkg.com/typescript/-/typescript-5.9.3.tgz#5b4f59e15310ab17a216f5d6cf53ee476ede670f" + integrity sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw== + ua-parser-js@^0.7.30: version "0.7.33" resolved "https://registry.yarnpkg.com/ua-parser-js/-/ua-parser-js-0.7.33.tgz#1d04acb4ccef9293df6f70f2c3d22f3030d8b532" @@ -16188,6 +16499,14 @@ update-browserslist-db@^1.1.4: escalade "^3.2.0" picocolors "^1.1.1" +update-check@^1.5.4: + version "1.5.4" + resolved "https://registry.yarnpkg.com/update-check/-/update-check-1.5.4.tgz#5b508e259558f1ad7dbc8b4b0457d4c9d28c8743" + integrity sha512-5YHsflzHP4t1G+8WGPlvKbJEbAJGCgw+Em+dGR1KmBUbr1J36SJBqlHLjR7oob7sco5hWHGQVcr9B2poIVDDTQ== + dependencies: + registry-auth-token "3.3.2" + registry-url "3.1.0" + upper-case-first@^2.0.2: version "2.0.2" resolved "https://registry.yarnpkg.com/upper-case-first/-/upper-case-first-2.0.2.tgz#992c3273f882abd19d1e02894cc147117f844324" @@ -16591,6 +16910,15 @@ wrap-ansi@7.0.0, wrap-ansi@^7.0.0: string-width "^4.1.0" strip-ansi "^6.0.0" +wrap-ansi@^5.1.0: + version "5.1.0" + resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-5.1.0.tgz#1fd1f67235d5b6d0fee781056001bfb694c03b09" + integrity sha512-QC1/iN/2/RPVJ5jYK8BGttj5z83LmSKmvbvrXPNCLZSEb32KKVDJDl/MOt2N01qU2H/FkzEa9PKto1BqDjtd7Q== + dependencies: + ansi-styles "^3.2.0" + string-width "^3.0.0" + strip-ansi "^5.0.0" + wrap-ansi@^6.2.0: version "6.2.0" resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-6.2.0.tgz#e9393ba07102e6c91a3b221478f0257cd2856e53" @@ -16719,16 +17047,16 @@ xml2js@0.6.2: sax ">=0.6.0" xmlbuilder "~11.0.0" +xmlbuilder@>=11.0.1, xmlbuilder@^15.1.1: + version "15.1.1" + resolved "https://registry.yarnpkg.com/xmlbuilder/-/xmlbuilder-15.1.1.tgz#9dcdce49eea66d8d10b42cae94a79c3c8d0c2ec5" + integrity sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg== + xmlbuilder@^14.0.0: version "14.0.0" resolved "https://registry.yarnpkg.com/xmlbuilder/-/xmlbuilder-14.0.0.tgz#876b5aec4f05ffd5feb97b0a871c855d16fbeb8c" integrity sha512-ts+B2rSe4fIckR6iquDjsKbQFK2NlUk6iG5nf14mDEyldgoc2nEKZ3jZWMPTxGQwVgToSjt6VGIho1H8/fNFTg== -xmlbuilder@^15.1.1: - version "15.1.1" - resolved "https://registry.yarnpkg.com/xmlbuilder/-/xmlbuilder-15.1.1.tgz#9dcdce49eea66d8d10b42cae94a79c3c8d0c2ec5" - integrity sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg== - xmlbuilder@^9.0.7, xmlbuilder@~9.0.1: version "9.0.7" resolved "https://registry.yarnpkg.com/xmlbuilder/-/xmlbuilder-9.0.7.tgz#132ee63d2ec5565c557e20f4c22df9aca686b10d" @@ -16814,6 +17142,14 @@ yargs-parser@21.1.1, yargs-parser@^21.0.1, yargs-parser@^21.1.1: resolved "https://registry.yarnpkg.com/yargs-parser/-/yargs-parser-21.1.1.tgz#9096bceebf990d21bb31fa9516e0ede294a77d35" integrity sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw== +yargs-parser@^13.1.2: + version "13.1.2" + resolved "https://registry.yarnpkg.com/yargs-parser/-/yargs-parser-13.1.2.tgz#130f09702ebaeef2650d54ce6e3e5706f7a4fb38" + integrity sha512-3lbsNRf/j+A4QuSZfDRA7HRSfWrzO0YjqTJd5kjAq37Zep1CEgaYmrH9Q3GwPiB9cHyd1Y1UwggGhJGoxipbzg== + dependencies: + camelcase "^5.0.0" + decamelize "^1.2.0" + yargs-parser@^18.1.2: version "18.1.3" resolved "https://registry.yarnpkg.com/yargs-parser/-/yargs-parser-18.1.3.tgz#be68c4975c6b2abf469236b0c870362fab09a7b0" @@ -16850,6 +17186,22 @@ yargs@17.7.2: y18n "^5.0.5" yargs-parser "^21.1.1" +yargs@^13.3.0: + version "13.3.2" + resolved "https://registry.yarnpkg.com/yargs/-/yargs-13.3.2.tgz#ad7ffefec1aa59565ac915f82dccb38a9c31a2dd" + integrity sha512-AX3Zw5iPruN5ie6xGRIDgqkT+ZhnRlZMLMHAs8tg7nRruy2Nb+i5o9bwghAogtM08q1dpr2LVoS8KSTMYpWXUw== + dependencies: + cliui "^5.0.0" + find-up "^3.0.0" + get-caller-file "^2.0.1" + require-directory "^2.1.1" + require-main-filename "^2.0.0" + set-blocking "^2.0.0" + string-width "^3.0.0" + which-module "^2.0.0" + y18n "^4.0.0" + yargs-parser "^13.1.2" + yargs@^15.3.1: version "15.4.1" resolved "https://registry.yarnpkg.com/yargs/-/yargs-15.4.1.tgz#0d87a16de01aee9d8bec2bfbf74f67851730f4f8"