Compare commits

..
Author SHA1 Message Date
Stanisław Chmiela ce10c42329 Handle missing iOS provisioning profile in resigning 2026-07-07 12:45:18 +02:00
Stanisław Chmiela 081e1a9cf7 [build-tools] Add iOS Simulator recorder (#3950)
<!-- If this PR requires a changelog entry, add it by commenting the PR with the command `/changelog-entry [breaking-change|new-feature|bug-fix|chore] [message]`. -->
<!-- You can skip the changelog check by labeling the PR with "no changelog". -->

# Why

Add a small Swift helper for recording iOS Simulator screen output from EAS build workers. The helper produces either segmented fMP4 output with manifest metadata for playlist construction or one continuous MP4 when segmentation is disabled.

# How

- Added a `record-sim` Swift package under `@expo/build-tools` resources.
- Added `yarn workspace @expo/build-tools build:record-sim` for local builds into `packages/build-tools/bin/record-sim`.
- Updated iOS worker packaging to build `record-sim` from source and copy only the executable into the worker archive.
- Ignored the generated local `bin/record-sim` binary.

# Test Plan

- `yarn workspace @expo/build-tools build:record-sim`
- `packages/build-tools/bin/record-sim --help`
- `bash -n packages/worker/package.sh`
- `yarn workspace @expo/build-tools typecheck`
- `./node_modules/.bin/oxfmt --check packages/build-tools/.gitignore packages/build-tools/package.json packages/worker/package.sh packages/build-tools/src/index.ts`
2026-07-07 12:29:14 +02:00
Gabe Debes bc6369204d [build-tools] Skip embedded bundle upload for dev client builds (#3940)
## Why

Dev client builds don't embed a bundle. We only skipped the upload for iOS simulators, so other dev builds warned about a missing bundle. This skips all dev client builds.

## Test plan

Added a unit test. CI passes.
2026-07-06 16:36:12 -07:00
Szymon Dziedzic 4855745d67 [eas-cli] Upload agent-device session artifacts (#3939)
* [eas-cli] Retain agent-device session artifacts

* [eas-cli] Upload agent-device session artifacts
2026-07-06 18:22:27 +02:00
Gabe Debes a848411278 [eas-cli] Re-upload assets stuck in polling loop when GCF finalize retry window is exhausted (#3918)
* [eas-cli] Retry uploading update assets that do not finish processing

* [eas-cli] Prefix changelog entry with [eas-cli]

* [eas-cli] Restore poll-window comment and parallelize asset limit fetch

* [eas-cli] Move changelog entry back under main
2026-07-02 15:18:53 -07:00
Gabe Debes 58968ddcd9 [eas-cli] Regenerate GraphQL schema and fix affected types (#3938) 2026-07-02 12:44:05 -07:00
Mustafa Abban 72ca5c2527 [build-tools] Apply npm cache registry only to the dependency install (#3925)
* [build-tools] Apply npm cache registry only to the dependency install

* cache install test

* inline env, remove deprecated env

* use cache url as signal
2026-07-02 10:17:30 -04:00
Stanisław Chmiela 8aa4cae097 [build-tools] Reset Gradle oom score before launch (#3898)
## Summary

Replace the delayed Gradle descendant `oom_score_adj` scan with an immediate shell-level reset before launching Gradle.

The worker service is protected with `OOMScoreAdjust=-999`, so Gradle inherits that value unless we reset it. This changes the Gradle launch wrapper to write `0` to `/proc/$$/oom_score_adj` before `exec ./gradlew ...`, which makes Gradle and its child processes inherit the default OOM score instead of the worker's protected score.

## Validation

- `yarn --cwd packages/build-tools typecheck`
- `yarn oxfmt --check packages/build-tools/src/android/gradle.ts packages/build-tools/src/steps/utils/android/gradle.ts`
- `yarn oxlint --type-aware --import-plugin --tsconfig ./tsconfig.oxlint.json packages/build-tools/src/android/gradle.ts packages/build-tools/src/steps/utils/android/gradle.ts`
2026-07-02 12:25:28 +02:00
Stanisław Chmiela 8a66d3808e [build-tools] Let updateEnv override existing values (#3901)
## Summary
- Change `BuildContext.updateEnv` so incoming values override existing env values.
- Add a regression test covering overrides and clearing via undefined.

## Why
This makes `updateEnv` behave like an update operation and lets callers intentionally refresh or clear existing environment entries.

No idea how it existed so long like this. I guess the values we update with don't overlap with the values we already have that often.

## Test Plan

CI should pass.
2026-07-02 11:06:20 +02:00
Expo CI 6697add2c0 update CHANGELOG.md 2026-07-01 16:37:38 +00:00
Expo CI cd47185f29 v20.5.1 2026-07-01 16:34:31 +00:00
88c51c3ccc [eas-cli] Fix Free plan users being falsely warned they hit their build limit (#3927)
The "over" usage tier fired whenever the summed value of any overageMetrics
row was greater than zero, independent of the plan limit or whether a charge
was actually incurred. For Free plans that message collapsed into the
"you've reached your limit, upgrade to keep building" warning, so free users
well below their limit (e.g. 6 builds) were told they were blocked.

Free plans can never be in an "over" state (they are blocked at the limit
and never charged pay-as-you-go), and even paid plans now require a real
billable overage cost (totalCost > 0) before showing the overage warning.

Co-authored-by: Sarah Lane <[email protected]>
Co-authored-by: Claude Opus 4.8 (1M context) <[email protected]>
2026-07-01 09:38:27 -06:00
46 changed files with 3497 additions and 593 deletions

No files matched your search

+10
View File
@@ -10,6 +10,16 @@ This is the log of notable changes to EAS CLI and related packages.
### 🐛 Bug fixes
- [build-tools] Skip embedded bundle upload for development client builds instead of warning that the bundle is missing. ([#3940](https://github.com/expo/eas-cli/pull/3940) by [@gwdp](https://github.com/gwdp))
- [eas-cli] Retry uploading assets that don't finish processing during `eas update`, instead of failing the update. ([#3918](https://github.com/expo/eas-cli/pull/3918) by [@gwdp](https://github.com/gwdp))
### 🧹 Chores
## [20.5.1](https://github.com/expo/eas-cli/releases/tag/v20.5.1) - 2026-07-01
### 🐛 Bug fixes
- [eas-cli] Fix Free plan users being warned they had reached their build limit while still well below it. Overage warnings are now shown only for paid plans with an actual billable overage cost. ([#3882](https://github.com/expo/eas-cli/pull/3882) by [@sarahlane8](https://github.com/sarahlane8))
- [eas-cli] Select correct tvOS build target for non-interactive Apple builds when EXPO_TV env variable is set. ([#3907](https://github.com/expo/eas-cli/pull/3907) by [@douglowder](https://github.com/douglowder))
### 🧹 Chores
+1 -1
View File
@@ -1,5 +1,5 @@
{
"npmClient": "yarn",
"version": "20.5.0",
"version": "20.5.1",
"$schema": "node_modules/lerna/schemas/lerna-schema.json"
}
+1
View File
@@ -1,5 +1,6 @@
node_modules/
dist/
bin/record-sim
.secrets*
!.secrets.example
+4 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@expo/build-tools",
"version": "20.4.0",
"version": "20.5.1",
"bugs": "https://github.com/expo/eas-cli/issues",
"license": "BUSL-1.1",
"author": "Expo <[email protected]>",
@@ -23,6 +23,7 @@
"watch": "tsc --watch --preserveWatchOutput",
"prebuild": "yarn gql",
"build": "tsc",
"build:record-sim": "mkdir -p bin && record_sim_bin_path=$(swift build -c release --package-path resources/record-sim --build-path resources/record-sim/.build --show-bin-path) && swift build -c release --package-path resources/record-sim --build-path resources/record-sim/.build && cp \"$record_sim_bin_path/record-sim\" bin/record-sim && chmod +x bin/record-sim",
"typecheck": "tsc",
"prepack": "rimraf dist \"*.tsbuildinfo\" && yarn gql && tsc -p tsconfig.build.json",
"jest-unit": "jest --config jest/unit-config.ts",
@@ -38,14 +39,14 @@
"@expo/config": "55.0.10",
"@expo/config-plugins": "55.0.7",
"@expo/downloader": "20.0.0",
"@expo/eas-build-job": "20.1.0",
"@expo/eas-build-job": "20.5.1",
"@expo/env": "^0.4.0",
"@expo/logger": "20.0.0",
"@expo/package-manager": "1.9.10",
"@expo/plist": "^0.2.0",
"@expo/results": "^1.0.0",
"@expo/spawn-async": "1.7.2",
"@expo/steps": "20.1.0",
"@expo/steps": "20.5.1",
"@expo/template-file": "20.0.0",
"@expo/turtle-spawn": "20.0.0",
"@expo/xcpretty": "^4.3.1",
@@ -0,0 +1,4 @@
.build/
.swiftpm/
DerivedData/
.DS_Store
@@ -0,0 +1,28 @@
// swift-tools-version: 5.9
import PackageDescription
let package = Package(
name: "record-sim",
platforms: [
.macOS(.v14),
],
products: [
.library(name: "RecordSim", targets: ["RecordSim"]),
.executable(name: "record-sim", targets: ["record-sim"]),
],
targets: [
.target(
name: "RecordSim",
linkerSettings: [
.linkedFramework("AVFoundation"),
.linkedFramework("CoreGraphics"),
.linkedFramework("CoreMedia"),
.linkedFramework("CoreVideo"),
.linkedFramework("IOSurface"),
.linkedFramework("UniformTypeIdentifiers"),
]
),
.executableTarget(name: "record-sim", dependencies: ["RecordSim"]),
]
)
@@ -0,0 +1,79 @@
# RecordSim
Small Swift library for non-exclusive iOS Simulator screen recording.
It implements one recording path:
1. Listen to SimRenderServer framebuffer callbacks.
2. Copy the live `IOSurface` immediately into an owned `CVPixelBuffer`.
3. Feed one continuous `AVAssetWriter`.
4. Emit Apple HLS/CMAF-style fragmented MP4 segments.
The output is suitable for uploading while recording. `session.json` contains
the ordered segment metadata needed to build a playlist later.
## Output
```text
session/
init.mp4
session.json
segments/
segment-000000.m4s
segment-000001.m4s
```
`init.mp4` is the fMP4 initialization segment. Individual `.m4s` files are not
standalone MP4 files. `session.json` stores the wall-clock timestamp for the
first video frame plus the metadata needed to build a playlist: HLS version,
target duration, media sequence, `initSegment`, and the ordered `segments` array
with file path and duration for each media segment.
## CLI
```bash
swift run record-sim \
--udid <BOOTED_SIMULATOR_UDID> \
--output /tmp/sim-recording \
--segment-duration 120 \
--fps 30 \
--bitrate 30000000 \
--codec h264
```
For sparse 2h sessions, `--segment-duration 120` is a reasonable default: about
60 media objects plus the init segment. The recorder appends a single duplicate
hold frame near each segment boundary when the simulator is idle, so long idle
periods still produce uploadable segments without encoding idle frames at 60fps.
Use `--segment-duration 0` to write one continuous `recording.mp4` instead of
`init.mp4` plus media segments.
Frame timestamps use monotonic host time, not wall-clock time. The recorder also
probes the framebuffer seed once per second; if the surface changes without
callbacks for 5 seconds, it captures that frame and rewires the private callback
registration.
## Library
```swift
let recorder = SimulatorRecorder(
configuration: SimulatorRecordingConfiguration(
deviceUDID: udid,
outputDirectory: sessionDirectory,
segmentDuration: 120
)
)
recorder.onSegment = { segment in
// Enqueue upload of segment.url.
}
try recorder.start()
try recorder.waitUntilFirstFrame()
// ...
let manifest = try recorder.stop()
```
`session.json` contains the precise wall-clock time for video PTS zero. In
segmented mode it also contains the init segment and ordered media segment
file/duration entries; in single-file mode it points at `recording.mp4`.
@@ -0,0 +1,192 @@
import Foundation
import IOSurface
import ObjectiveC
final class FramebufferDisplaySource {
struct SurfaceSnapshot {
let surface: IOSurface
let width: Int
let height: Int
let seed: UInt32
}
private let deviceUDID: String
private let callbackQueue: DispatchQueue
private let onFrame: () -> Void
private let onSurfaceChange: () -> Void
private var descriptors: [NSObject] = []
private var callbackUUIDs: [ObjectIdentifier: NSUUID] = [:]
private var retainedBlocks: [AnyObject] = []
private var ioClient: NSObject?
init(
deviceUDID: String,
callbackQueue: DispatchQueue,
onFrame: @escaping () -> Void,
onSurfaceChange: @escaping () -> Void
) {
self.deviceUDID = deviceUDID
self.callbackQueue = callbackQueue
self.onFrame = onFrame
self.onSurfaceChange = onSurfaceChange
}
func start() throws {
try PrivateSimulatorFrameworks.load()
guard let device = SimulatorDeviceLookup.find(udid: deviceUDID) else {
throw RecorderError.make(1, "Simulator \(deviceUDID) not found")
}
let state = device.value(forKey: "stateString") as? String ?? "unknown"
guard state == "Booted" else {
throw RecorderError.make(2, "Simulator \(deviceUDID) is not booted (state: \(state))")
}
guard
let io = device.perform(NSSelectorFromString("io"))?.takeUnretainedValue() as? NSObject
else {
throw RecorderError.make(3, "Failed to get simulator IO client")
}
ioClient = io
try wireUpFramebuffer()
}
func stop() {
unregisterCallbacks()
descriptors.removeAll()
retainedBlocks.removeAll()
ioClient = nil
}
func surfaceSnapshot() -> SurfaceSnapshot? {
let surfaceSelector = NSSelectorFromString("framebufferSurface")
var bestSnapshot: SurfaceSnapshot?
var bestArea = 0
for descriptor in descriptors {
guard let surfaceObject = descriptor.perform(surfaceSelector)?.takeUnretainedValue()
else {
continue
}
let surface = unsafeBitCast(surfaceObject, to: IOSurface.self)
let width = IOSurfaceGetWidth(surface)
let height = IOSurfaceGetHeight(surface)
let area = width * height
if area > bestArea {
bestSnapshot = SurfaceSnapshot(
surface: surface,
width: width,
height: height,
seed: IOSurfaceGetSeed(surface)
)
bestArea = area
}
}
return bestSnapshot
}
func rewireFramebuffer() throws {
try wireUpFramebuffer()
}
private func wireUpFramebuffer() throws {
guard let io = ioClient else {
throw RecorderError.make(3, "No simulator IO client")
}
io.perform(NSSelectorFromString("updateIOPorts"))
let nextDescriptors = try findFramebufferDescriptors(io: io)
unregisterCallbacks()
descriptors = nextDescriptors
retainedBlocks.removeAll()
do {
for descriptor in descriptors {
try registerCallbacks(descriptor: descriptor)
}
} catch {
unregisterCallbacks()
descriptors.removeAll()
retainedBlocks.removeAll()
throw error
}
}
private func findFramebufferDescriptors(io: NSObject) throws -> [NSObject] {
guard let ports = io.value(forKey: "deviceIOPorts") as? [NSObject] else {
throw RecorderError.make(4, "Failed to read simulator IO ports")
}
let portIdentifierSelector = NSSelectorFromString("portIdentifier")
let descriptorSelector = NSSelectorFromString("descriptor")
let surfaceSelector = NSSelectorFromString("framebufferSurface")
var candidates: [NSObject] = []
for port in ports {
guard port.responds(to: portIdentifierSelector),
let portIdentifier = port.perform(portIdentifierSelector)?.takeUnretainedValue(),
"\(portIdentifier)" == "com.apple.framebuffer.display",
port.responds(to: descriptorSelector),
let descriptor = port.perform(descriptorSelector)?.takeUnretainedValue()
as? NSObject,
descriptor.responds(to: surfaceSelector)
else {
continue
}
candidates.append(descriptor)
}
if candidates.isEmpty {
throw RecorderError.make(5, "No framebuffer display descriptor found")
}
return candidates
}
private func registerCallbacks(descriptor: NSObject) throws {
let selector = NSSelectorFromString(
"registerScreenCallbacksWithUUID:callbackQueue:frameCallback:surfacesChangedCallback:propertiesChangedCallback:"
)
guard descriptor.responds(to: selector) else {
throw RecorderError.make(6, "Framebuffer descriptor does not support screen callbacks")
}
guard let msgSendPointer = dlsym(UnsafeMutableRawPointer(bitPattern: -2), "objc_msgSend")
else {
throw RecorderError.make(7, "objc_msgSend not found")
}
typealias MsgSend =
@convention(c) (
AnyObject, Selector, AnyObject, AnyObject, AnyObject, AnyObject, AnyObject
) -> Void
let msgSend = unsafeBitCast(msgSendPointer, to: MsgSend.self)
let uuid = NSUUID()
callbackUUIDs[ObjectIdentifier(descriptor)] = uuid
let frameCallback: @convention(block) () -> Void = { [weak self] in
self?.onFrame()
}
let surfacesCallback: @convention(block) (AnyObject?, AnyObject?) -> Void = {
[weak self] _, _ in
self?.onSurfaceChange()
}
let propertiesCallback: @convention(block) () -> Void = {}
retainedBlocks.append(frameCallback as AnyObject)
retainedBlocks.append(surfacesCallback as AnyObject)
retainedBlocks.append(propertiesCallback as AnyObject)
msgSend(
descriptor,
selector,
uuid,
callbackQueue as AnyObject,
frameCallback as AnyObject,
surfacesCallback as AnyObject,
propertiesCallback as AnyObject
)
}
private func unregisterCallbacks() {
let selector = NSSelectorFromString("unregisterScreenCallbacksWithUUID:")
for descriptor in descriptors {
if let uuid = callbackUUIDs[ObjectIdentifier(descriptor)],
descriptor.responds(to: selector)
{
descriptor.perform(selector, with: uuid)
}
}
callbackUUIDs.removeAll()
}
}
@@ -0,0 +1,96 @@
import Foundation
import ObjectiveC
enum XcodeDeveloperDirectory {
static let current: String = {
if let developerDir = ProcessInfo.processInfo.environment["DEVELOPER_DIR"],
!developerDir.isEmpty
{
return developerDir
}
let fallback = "/Applications/Xcode.app/Contents/Developer"
let pipe = Pipe()
let process = Process()
process.executableURL = URL(fileURLWithPath: "/usr/bin/xcode-select")
process.arguments = ["-p"]
process.standardOutput = pipe
do {
try process.run()
} catch {
return fallback
}
process.waitUntilExit()
return String(data: pipe.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8)?
.trimmingCharacters(in: .whitespacesAndNewlines) ?? fallback
}()
}
enum PrivateSimulatorFrameworks {
static func load() throws {
try loadResult.get()
}
private static let loadResult: Result<Void, Error> = {
let developerDir = XcodeDeveloperDirectory.current
let candidates = [
"/Library/Developer/PrivateFrameworks/CoreSimulator.framework/CoreSimulator",
"\(developerDir)/Library/PrivateFrameworks/CoreSimulator.framework/CoreSimulator",
"\(developerDir)/../SharedFrameworks/SimulatorKit.framework/SimulatorKit",
"\(developerDir)/Library/PrivateFrameworks/SimulatorKit.framework/SimulatorKit",
]
var failures: [String] = []
for path in candidates {
if dlopen(path, RTLD_NOW) == nil {
let message = dlerror().map { String(cString: $0) } ?? "unknown error"
failures.append("\(path): \(message)")
}
}
guard NSClassFromString("SimServiceContext") != nil else {
let tried = candidates.joined(separator: ", ")
let errors = failures.isEmpty ? "no dlopen errors" : failures.joined(separator: "; ")
return .failure(
RecorderError.make(
8,
"Failed to load CoreSimulator/SimulatorKit (tried: \(tried); errors: \(errors))"
)
)
}
return .success(())
}()
}
enum SimulatorDeviceLookup {
private static let context: NSObject? = {
guard let contextClass = NSClassFromString("SimServiceContext") as? NSObject.Type else {
return nil
}
let sharedSelector = NSSelectorFromString("sharedServiceContextForDeveloperDir:error:")
return contextClass.perform(
sharedSelector, with: XcodeDeveloperDirectory.current, with: nil)?
.takeUnretainedValue() as? NSObject
}()
static func find(udid: String) -> NSObject? {
guard let context else {
return nil
}
let deviceSetSelector = NSSelectorFromString("defaultDeviceSetWithError:")
guard
let deviceSet = context.perform(deviceSetSelector, with: nil)?
.takeUnretainedValue() as? NSObject
else {
return nil
}
guard let devices = deviceSet.value(forKey: "devices") as? [NSObject] else {
return nil
}
return devices.first {
($0.value(forKey: "UDID") as? NSUUID)?.uuidString == udid
}
}
static func stateString(udid: String) -> String? {
find(udid: udid)?.value(forKey: "stateString") as? String
}
}
@@ -0,0 +1,70 @@
import Foundation
public enum RecorderCodec: String, Codable, Sendable {
case h264
case hevc
}
public struct SimulatorRecordingConfiguration: Sendable {
public var deviceUDID: String
public var outputDirectory: URL
public var fps: Int
public var bitrate: Int
public var codec: RecorderCodec
public var segmentDuration: TimeInterval
public var maxPendingFrames: Int
public var overwrite: Bool
public init(
deviceUDID: String,
outputDirectory: URL,
fps: Int = 30,
bitrate: Int = 30_000_000,
codec: RecorderCodec = .h264,
segmentDuration: TimeInterval = 120,
maxPendingFrames: Int = 8,
overwrite: Bool = true
) {
self.deviceUDID = deviceUDID
self.outputDirectory = outputDirectory
self.fps = fps
self.bitrate = bitrate
self.codec = codec
self.segmentDuration = segmentDuration
self.maxPendingFrames = maxPendingFrames
self.overwrite = overwrite
}
}
public enum SegmentKind: String, Codable, Sendable {
case initialization
case media
}
public struct SegmentOutput: Sendable {
public let kind: SegmentKind
public let relativePath: String
public let url: URL
public let byteCount: Int
public let durationSeconds: Double?
}
public struct MediaSegmentRecord: Codable, Sendable {
public let file: String
public let durationSeconds: Double
}
public struct FirstFrameWallClock: Codable, Sendable {
public let unixMs: Int64
public let iso8601: String
}
public struct RecordingManifest: Codable, Sendable {
public let firstFrameWallClock: FirstFrameWallClock
public let hlsVersion: Int?
public let hlsTargetDurationSeconds: Int?
public let hlsMediaSequence: Int?
public let recording: String?
public let initSegment: String?
public let segments: [MediaSegmentRecord]
}
@@ -0,0 +1,136 @@
import AVFoundation
import Foundation
final class RecordingOutputWriter: NSObject, AVAssetWriterDelegate {
private let rootDirectory: URL
private let segmentsDirectory: URL
let singleRecordingURL: URL
private let stateQueue = DispatchQueue(label: "record-sim.segment-writer")
private let notificationQueue = DispatchQueue(label: "record-sim.segment-notifications")
private let onSegment: ((SegmentOutput) -> Void)?
private var initSegmentPath: String?
private var segments: [MediaSegmentRecord] = []
private var nextMediaSegmentIndex = 0
private var firstError: Error?
init(rootDirectory: URL, onSegment: ((SegmentOutput) -> Void)?) {
self.rootDirectory = rootDirectory
self.segmentsDirectory = rootDirectory.appendingPathComponent("segments", isDirectory: true)
self.singleRecordingURL = rootDirectory.appendingPathComponent("recording.mp4")
self.onSegment = onSegment
super.init()
}
func prepare(overwrite: Bool, segmented: Bool) throws {
if overwrite {
try? FileManager.default.removeItem(at: rootDirectory)
}
try FileManager.default.createDirectory(at: rootDirectory, withIntermediateDirectories: true)
if segmented {
try FileManager.default.createDirectory(at: segmentsDirectory, withIntermediateDirectories: true)
}
}
var error: Error? {
stateQueue.sync { firstError }
}
func assetWriter(
_ writer: AVAssetWriter,
didOutputSegmentData segmentData: Data,
segmentType: AVAssetSegmentType,
segmentReport: AVAssetSegmentReport?
) {
var notification: SegmentOutput?
stateQueue.sync {
if firstError != nil {
return
}
do {
switch segmentType {
case .initialization:
let relativePath = "init.mp4"
let url = rootDirectory.appendingPathComponent(relativePath)
try segmentData.write(to: url, options: .atomic)
initSegmentPath = relativePath
notification = SegmentOutput(
kind: .initialization,
relativePath: relativePath,
url: url,
byteCount: segmentData.count,
durationSeconds: nil
)
case .separable:
let index = nextMediaSegmentIndex
let relativePath = String(format: "segments/segment-%06d.m4s", index)
let url = rootDirectory.appendingPathComponent(relativePath)
try segmentData.write(to: url, options: .atomic)
let videoReport = segmentReport?.trackReports.first(where: { $0.mediaType == .video })
let duration = videoReport.flatMap { timeSeconds($0.duration) } ?? 0
let record = MediaSegmentRecord(
file: relativePath,
durationSeconds: duration
)
nextMediaSegmentIndex += 1
segments.append(record)
notification = SegmentOutput(
kind: .media,
relativePath: relativePath,
url: url,
byteCount: segmentData.count,
durationSeconds: duration
)
default:
break
}
} catch {
firstError = error
}
}
if let notification, let onSegment {
notificationQueue.async {
onSegment(notification)
}
}
}
@discardableResult
func writeManifest(
configuration: SimulatorRecordingConfiguration,
firstFrameWallClock: Date
) throws -> RecordingManifest {
try stateQueue.sync {
if let firstError {
throw firstError
}
let segmented = configuration.segmentDuration > 0
if segmented, initSegmentPath == nil {
throw RecorderError.make(50, "Missing initialization segment")
}
if segmented, segments.isEmpty {
throw RecorderError.make(51, "No media segments were written")
}
let targetDuration = segmented
? max(1, Int(ceil(segments.map(\.durationSeconds).max() ?? configuration.segmentDuration)))
: nil
let manifest = RecordingManifest(
firstFrameWallClock: FirstFrameWallClock(
unixMs: unixMs(firstFrameWallClock),
iso8601: iso8601(firstFrameWallClock)
),
hlsVersion: segmented ? 7 : nil,
hlsTargetDurationSeconds: targetDuration,
hlsMediaSequence: segmented ? 0 : nil,
recording: segmented ? nil : "recording.mp4",
initSegment: segmented ? initSegmentPath : nil,
segments: segments
)
let data = try JSONEncoder.prettySorted.encode(manifest)
try data.write(to: rootDirectory.appendingPathComponent("session.json"), options: .atomic)
return manifest
}
}
}
@@ -0,0 +1,632 @@
import AVFoundation
import CoreVideo
import Foundation
import IOSurface
import UniformTypeIdentifiers
public final class SimulatorRecorder {
public var onSegment: ((SegmentOutput) -> Void)?
public var onSimulatorStopped: ((String) -> Void)?
private static let callbackStalenessTimeout: TimeInterval = 5
private static let firstFrameRewireInterval: TimeInterval = 1
private let configuration: SimulatorRecordingConfiguration
private let callbackQueue = DispatchQueue(
label: "record-sim.frame-callbacks", qos: .userInteractive)
private let writerQueue = DispatchQueue(label: "record-sim.asset-writer", qos: .userInitiated)
private let eventQueue = DispatchQueue(label: "record-sim.events", qos: .utility)
private let pendingLock = NSLock()
private var pendingFrames = 0
private var displaySource: FramebufferDisplaySource?
private var outputWriter: RecordingOutputWriter?
private var writer: AVAssetWriter?
private var input: AVAssetWriterInput?
private var adaptor: AVAssetWriterInputPixelBufferAdaptor?
private var boundaryTimer: DispatchSourceTimer?
private var monotonicClock = MonotonicClock()
private var firstAcceptedCaptureTime: CMTime?
private var firstAcceptedWallClock: Date?
private var lastPTS: CMTime?
private var lastSeed: UInt32?
private var lastFrameCallbackElapsed: TimeInterval?
private var lastFirstFrameRewireElapsed: TimeInterval = 0
private var lastAppendedPixelBuffer: CVPixelBuffer?
private var nextBoundaryElapsed: TimeInterval = 0
private var simulatorStoppedReason: String?
private var stopped = false
private var firstFrameReady = false
private let firstFrameSemaphore = DispatchSemaphore(value: 0)
private var firstError: Error?
public init(configuration: SimulatorRecordingConfiguration) {
self.configuration = configuration
}
public func start() throws {
try validateConfiguration()
let outputWriter = RecordingOutputWriter(
rootDirectory: configuration.outputDirectory,
onSegment: { [weak self] segment in
self?.onSegment?(segment)
})
try outputWriter.prepare(
overwrite: configuration.overwrite, segmented: configuration.segmentDuration > 0)
self.outputWriter = outputWriter
monotonicClock = MonotonicClock()
nextBoundaryElapsed = configuration.segmentDuration
lastFirstFrameRewireElapsed = 0
stopped = false
let source = FramebufferDisplaySource(
deviceUDID: configuration.deviceUDID,
callbackQueue: callbackQueue,
onFrame: { [weak self] in
self?.captureFrame(force: false, reason: .callback)
},
onSurfaceChange: { [weak self] in
self?.captureFrame(force: true, reason: .surfaceChange)
}
)
try source.start()
displaySource = source
startBoundaryTimer()
}
public func waitUntilFirstFrame(timeoutSeconds: TimeInterval = 15) throws {
let alreadyReady = try writerQueue.sync {
if let firstError {
throw firstError
}
return firstFrameReady
}
if alreadyReady {
return
}
if firstFrameSemaphore.wait(timeout: .now() + timeoutSeconds) == .timedOut {
throw RecorderError.make(20, "Timed out waiting for first frame")
}
try writerQueue.sync {
if let firstError {
throw firstError
}
if !firstFrameReady {
throw RecorderError.make(21, "Recorder stopped before first frame")
}
}
}
public func simulatorStopReason() -> String? {
callbackQueue.sync {
simulatorStoppedReason
}
}
@discardableResult
public func stop() throws -> RecordingManifest {
callbackQueue.sync {
stopped = true
boundaryTimer?.cancel()
boundaryTimer = nil
displaySource?.stop()
displaySource = nil
}
return try writerQueue.sync {
if let firstError {
throw firstError
}
guard let writer, let input, let outputWriter else {
let error = RecorderError.make(22, "No frames were captured")
firstError = error
signalFirstFrameReadyIfNeeded()
throw error
}
try appendTailFrameIfNeeded(finalPTS: monotonicClock.elapsedTime())
input.markAsFinished()
let semaphore = DispatchSemaphore(value: 0)
writer.finishWriting {
semaphore.signal()
}
if semaphore.wait(timeout: .now() + 60) == .timedOut {
throw RecorderError.make(23, "Timed out finishing AVAssetWriter")
}
if writer.status == .failed {
throw writer.error ?? RecorderError.make(24, "AVAssetWriter failed")
}
if let error = outputWriter.error {
throw error
}
guard let firstAcceptedWallClock else {
throw RecorderError.make(25, "Missing first frame wall-clock timestamp")
}
return try outputWriter.writeManifest(
configuration: configuration,
firstFrameWallClock: firstAcceptedWallClock
)
}
}
private enum CaptureReason {
case callback
case surfaceChange
case boundary
case healthProbe
}
private func validateConfiguration() throws {
guard !configuration.deviceUDID.isEmpty else {
throw RecorderError.make(30, "deviceUDID must not be empty")
}
guard configuration.fps > 0, configuration.fps <= 120 else {
throw RecorderError.make(31, "fps must be between 1 and 120")
}
guard configuration.bitrate > 0 else {
throw RecorderError.make(32, "bitrate must be positive")
}
guard configuration.segmentDuration >= 0 else {
throw RecorderError.make(33, "segmentDuration must be non-negative")
}
guard configuration.maxPendingFrames > 0 else {
throw RecorderError.make(34, "maxPendingFrames must be positive")
}
}
private func startBoundaryTimer() {
let timer = DispatchSource.makeTimerSource(queue: callbackQueue)
timer.schedule(deadline: .now() + 1, repeating: 1, leeway: .milliseconds(100))
timer.setEventHandler { [weak self] in
self?.probeSimulatorState()
self?.probeFramebufferHealth()
self?.appendBoundaryFrameIfNeeded()
}
timer.resume()
boundaryTimer = timer
}
private func appendBoundaryFrameIfNeeded() {
if stopped {
return
}
guard configuration.segmentDuration > 0 else {
return
}
writerQueue.sync {
guard firstAcceptedCaptureTime != nil else {
return
}
let currentPTSSeconds = CMTimeGetSeconds(
normalizedPresentationTime(for: monotonicClock.elapsedTime()))
guard currentPTSSeconds >= nextBoundaryElapsed else {
return
}
let boundary = nextBoundaryElapsed
guard let lastPTS else {
return
}
do {
if CMTimeGetSeconds(lastPTS) < boundary {
let boundaryPTS = CMTime(seconds: boundary, preferredTimescale: 1_000_000_000)
guard try appendHeldFrame(at: boundaryPTS) else {
return
}
}
nextBoundaryElapsed = boundary + configuration.segmentDuration
} catch {
firstError = error
signalFirstFrameReadyIfNeeded()
}
}
}
private func captureFrame(force: Bool, reason: CaptureReason) {
if stopped {
return
}
if reason == .callback {
lastFrameCallbackElapsed = monotonicClock.elapsedSeconds()
}
guard let snapshot = displaySource?.surfaceSnapshot() else {
return
}
let surface = snapshot.surface
let surfaceWidth = snapshot.width
let surfaceHeight = snapshot.height
guard surfaceWidth > 0, surfaceHeight > 0 else {
return
}
let seed = snapshot.seed
let matchesLastAppendedSeed = writerQueue.sync { lastSeed == seed }
if !force, matchesLastAppendedSeed {
return
}
guard reservePendingFrame() else {
return
}
let capturedAt = monotonicClock.elapsedTime()
let capturedAtWallClock = Date()
do {
let pixelBuffer = try copySurfaceToOwnedPixelBuffer(
surface,
width: surfaceWidth,
height: surfaceHeight
)
appendOwned(
pixelBuffer: pixelBuffer,
seed: seed,
capturedAt: capturedAt,
capturedAtWallClock: capturedAtWallClock
)
} catch {
releasePendingFrame()
writerQueue.async {
self.firstError = error
self.signalFirstFrameReadyIfNeeded()
}
}
}
private func probeFramebufferHealth() {
guard !stopped, let displaySource else {
return
}
let elapsed = monotonicClock.elapsedSeconds()
if !isFirstFrameReady(),
elapsed - lastFirstFrameRewireElapsed >= Self.firstFrameRewireInterval
{
lastFirstFrameRewireElapsed = elapsed
rewireFramebuffer()
captureFrame(force: true, reason: .healthProbe)
return
}
guard let snapshot = displaySource.surfaceSnapshot(),
snapshot.width > 0,
snapshot.height > 0
else {
rewireFramebuffer()
return
}
let observedSeed = snapshot.seed
let lastAppendedSeed = writerQueue.sync { lastSeed }
guard let lastAppendedSeed, observedSeed != lastAppendedSeed else {
return
}
let lastCallback = lastFrameCallbackElapsed ?? 0
if elapsed - lastCallback >= Self.callbackStalenessTimeout {
captureFrame(force: false, reason: .healthProbe)
rewireFramebuffer()
}
}
private func probeSimulatorState() {
guard !stopped, simulatorStoppedReason == nil else {
return
}
let state = SimulatorDeviceLookup.stateString(udid: configuration.deviceUDID) ?? "missing"
guard state != "Booted" else {
return
}
simulatorStoppedReason = state
let error = RecorderError.make(26, "Simulator stopped before first frame (state: \(state))")
writerQueue.async {
if self.firstError == nil, !self.firstFrameReady {
self.firstError = error
self.signalFirstFrameReadyIfNeeded()
}
}
let onSimulatorStopped = onSimulatorStopped
eventQueue.async {
onSimulatorStopped?(state)
}
}
private func rewireFramebuffer() {
guard let displaySource else {
return
}
do {
try displaySource.rewireFramebuffer()
} catch {
writerQueue.async {
self.firstError = error
self.signalFirstFrameReadyIfNeeded()
}
}
}
private func reservePendingFrame() -> Bool {
pendingLock.lock()
defer { pendingLock.unlock() }
if pendingFrames >= configuration.maxPendingFrames {
return false
}
pendingFrames += 1
return true
}
private func releasePendingFrame() {
pendingLock.lock()
pendingFrames = max(0, pendingFrames - 1)
pendingLock.unlock()
}
private func copySurfaceToOwnedPixelBuffer(
_ surface: IOSurface,
width: Int,
height: Int
) throws -> CVPixelBuffer {
let pool = try writerQueue.sync {
if let firstError {
throw firstError
}
if writer == nil {
try startWriter(width: width, height: height)
}
guard let pool = adaptor?.pixelBufferPool else {
throw RecorderError.make(40, "AVAssetWriter pixel buffer pool is unavailable")
}
return pool
}
var output: CVPixelBuffer?
let status = CVPixelBufferPoolCreatePixelBuffer(kCFAllocatorDefault, pool, &output)
guard status == kCVReturnSuccess, let destination = output else {
throw RecorderError.make(41, "Failed to allocate pixel buffer from pool: \(status)")
}
let destinationLockStatus = CVPixelBufferLockBaseAddress(destination, [])
guard destinationLockStatus == kCVReturnSuccess else {
throw RecorderError.make(50, "Failed to lock pixel buffer: \(destinationLockStatus)")
}
let surfaceLockStatus = IOSurfaceLock(surface, .readOnly, nil)
guard surfaceLockStatus == KERN_SUCCESS else {
CVPixelBufferUnlockBaseAddress(destination, [])
throw RecorderError.make(49, "Failed to lock IOSurface: \(surfaceLockStatus)")
}
defer {
IOSurfaceUnlock(surface, .readOnly, nil)
CVPixelBufferUnlockBaseAddress(destination, [])
}
guard let destinationAddress = CVPixelBufferGetBaseAddress(destination) else {
throw RecorderError.make(42, "Pixel buffer has no base address")
}
let sourceAddress = IOSurfaceGetBaseAddress(surface)
let sourceStride = IOSurfaceGetBytesPerRow(surface)
let destinationStride = CVPixelBufferGetBytesPerRow(destination)
let copyWidth = min(width, IOSurfaceGetWidth(surface), CVPixelBufferGetWidth(destination))
let copyHeight = min(
height, IOSurfaceGetHeight(surface), CVPixelBufferGetHeight(destination))
let copyBytes = min(copyWidth * 4, sourceStride, destinationStride)
for row in 0..<copyHeight {
memcpy(
destinationAddress.advanced(by: row * destinationStride),
sourceAddress.advanced(by: row * sourceStride),
copyBytes
)
}
return destination
}
private func appendOwned(
pixelBuffer: CVPixelBuffer,
seed: UInt32,
capturedAt: CMTime,
capturedAtWallClock: Date
) {
writerQueue.async {
defer { self.releasePendingFrame() }
if self.firstError != nil {
return
}
do {
try self.appendOnWriterQueue(
pixelBuffer: pixelBuffer,
seed: seed,
capturedAt: capturedAt,
capturedAtWallClock: capturedAtWallClock
)
} catch {
self.firstError = error
self.signalFirstFrameReadyIfNeeded()
}
}
}
private func appendOnWriterQueue(
pixelBuffer: CVPixelBuffer,
seed: UInt32,
capturedAt: CMTime,
capturedAtWallClock: Date
) throws {
guard let writer, let input, let adaptor else {
throw RecorderError.make(43, "AVAssetWriter is not initialized")
}
let isFirstFrame = firstAcceptedCaptureTime == nil
let pts = isFirstFrame ? .zero : normalizedPresentationTime(for: capturedAt)
if let lastPTS, CMTimeCompare(pts, lastPTS) <= 0 {
return
}
guard try appendPendingBoundaryFrames(before: pts) else {
return
}
guard input.isReadyForMoreMediaData else {
return
}
if !adaptor.append(pixelBuffer, withPresentationTime: pts) {
throw writer.error ?? RecorderError.make(44, "Failed to append pixel buffer")
}
if isFirstFrame {
firstAcceptedCaptureTime = capturedAt
firstAcceptedWallClock = capturedAtWallClock
}
lastPTS = pts
lastSeed = seed
lastAppendedPixelBuffer = pixelBuffer
signalFirstFrameReadyIfNeeded()
}
private func appendTailFrameIfNeeded(finalPTS: CMTime) throws {
let deadline = Date().addingTimeInterval(5)
while true {
if try appendTailFrame(finalPTS: finalPTS) {
return
}
if Date() >= deadline {
throw RecorderError.make(45, "Timed out waiting to append tail frame")
}
Thread.sleep(forTimeInterval: 0.05)
}
}
private func appendTailFrame(finalPTS: CMTime) throws -> Bool {
let pts = normalizedPresentationTime(for: finalPTS)
return try appendHeldFrame(at: pts)
}
private func appendPendingBoundaryFrames(before pts: CMTime) throws -> Bool {
guard configuration.segmentDuration > 0,
firstAcceptedCaptureTime != nil
else {
return true
}
while CMTimeGetSeconds(pts) > nextBoundaryElapsed {
if let lastPTS, CMTimeGetSeconds(lastPTS) >= nextBoundaryElapsed {
nextBoundaryElapsed += configuration.segmentDuration
continue
}
let boundaryPTS = CMTime(
seconds: nextBoundaryElapsed, preferredTimescale: 1_000_000_000)
guard try appendHeldFrame(at: boundaryPTS) else {
return false
}
nextBoundaryElapsed += configuration.segmentDuration
}
return true
}
private func appendHeldFrame(at pts: CMTime) throws -> Bool {
guard let input,
let adaptor,
let lastPTS,
let lastAppendedPixelBuffer
else {
return true
}
if CMTimeCompare(pts, lastPTS) <= 0 {
return true
}
guard input.isReadyForMoreMediaData else {
return false
}
if !adaptor.append(lastAppendedPixelBuffer, withPresentationTime: pts) {
throw writer?.error ?? RecorderError.make(45, "Failed to append held frame")
}
self.lastPTS = pts
return true
}
private func normalizedPresentationTime(for capturedAt: CMTime) -> CMTime {
let start = firstAcceptedCaptureTime ?? capturedAt
return CMTimeSubtract(capturedAt, start)
}
private func startWriter(width: Int, height: Int) throws {
guard let outputWriter else {
throw RecorderError.make(46, "Missing output writer")
}
let writer: AVAssetWriter
if configuration.segmentDuration > 0 {
writer = AVAssetWriter(contentType: .mpeg4Movie)
writer.delegate = outputWriter
writer.outputFileTypeProfile = .mpeg4AppleHLS
writer.preferredOutputSegmentInterval = CMTime(
seconds: configuration.segmentDuration,
preferredTimescale: 600
)
} else {
writer = try AVAssetWriter(outputURL: outputWriter.singleRecordingURL, fileType: .mp4)
}
writer.initialSegmentStartTime = .zero
let input = AVAssetWriterInput(
mediaType: .video,
outputSettings: assetWriterSettings(width: width, height: height)
)
input.expectsMediaDataInRealTime = true
let attributes: [String: Any] = [
kCVPixelBufferPixelFormatTypeKey as String: kCVPixelFormatType_32BGRA,
kCVPixelBufferWidthKey as String: width,
kCVPixelBufferHeightKey as String: height,
kCVPixelBufferIOSurfacePropertiesKey as String: [:],
]
let adaptor = AVAssetWriterInputPixelBufferAdaptor(
assetWriterInput: input,
sourcePixelBufferAttributes: attributes
)
guard writer.canAdd(input) else {
throw RecorderError.make(47, "AVAssetWriter cannot add video input")
}
writer.add(input)
guard writer.startWriting() else {
throw writer.error ?? RecorderError.make(48, "AVAssetWriter failed to start")
}
writer.startSession(atSourceTime: .zero)
self.writer = writer
self.input = input
self.adaptor = adaptor
}
private func assetWriterSettings(width: Int, height: Int) -> [String: Any] {
let codecType: AVVideoCodecType = configuration.codec == .hevc ? .hevc : .h264
var compression: [String: Any] = [
AVVideoAverageBitRateKey: configuration.bitrate,
AVVideoExpectedSourceFrameRateKey: configuration.fps,
AVVideoAllowFrameReorderingKey: true,
]
if configuration.segmentDuration > 0 {
compression[AVVideoMaxKeyFrameIntervalKey] = max(
1,
Int((configuration.segmentDuration * Double(configuration.fps)).rounded())
)
}
if configuration.codec == .h264 {
compression[AVVideoProfileLevelKey] = AVVideoProfileLevelH264HighAutoLevel
}
return [
AVVideoCodecKey: codecType,
AVVideoWidthKey: width,
AVVideoHeightKey: height,
AVVideoCompressionPropertiesKey: compression,
]
}
private func signalFirstFrameReadyIfNeeded() {
if firstFrameReady {
return
}
firstFrameReady = true
firstFrameSemaphore.signal()
}
private func isFirstFrameReady() -> Bool {
writerQueue.sync {
firstFrameReady
}
}
}
@@ -0,0 +1,60 @@
import CoreMedia
import Foundation
enum RecorderError {
static func make(_ code: Int, _ message: String) -> NSError {
NSError(
domain: "RecordSim",
code: code,
userInfo: [NSLocalizedDescriptionKey: message]
)
}
}
extension JSONEncoder {
static var prettySorted: JSONEncoder {
let encoder = JSONEncoder()
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
return encoder
}
}
func timeSeconds(_ time: CMTime) -> Double? {
guard time.isValid, !time.isIndefinite, !time.isNegativeInfinity, !time.isPositiveInfinity else {
return nil
}
return CMTimeGetSeconds(time)
}
func iso8601(_ date: Date) -> String {
let formatter = ISO8601DateFormatter()
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
formatter.timeZone = TimeZone(secondsFromGMT: 0)
return formatter.string(from: date)
}
func unixMs(_ date: Date) -> Int64 {
Int64((date.timeIntervalSince1970 * 1000).rounded())
}
struct MonotonicClock {
private static let timescale: CMTimeScale = 1_000_000_000
let startedAtUptimeNanoseconds: UInt64
init() {
startedAtUptimeNanoseconds = DispatchTime.now().uptimeNanoseconds
}
func elapsedSeconds() -> TimeInterval {
TimeInterval(elapsedNanoseconds()) / TimeInterval(Self.timescale)
}
func elapsedTime() -> CMTime {
CMTime(value: CMTimeValue(elapsedNanoseconds()), timescale: Self.timescale)
}
private func elapsedNanoseconds() -> UInt64 {
DispatchTime.now().uptimeNanoseconds - startedAtUptimeNanoseconds
}
}
@@ -0,0 +1,143 @@
import Foundation
import RecordSim
struct CLIOptions {
var udid: String?
var output: String?
var segmentDuration: TimeInterval = 120
var fps = 30
var bitrate = 30_000_000
var codec: RecorderCodec = .h264
}
private var stopRequested: CInt = 0
private var simulatorStopped: CInt = 0
private func handleStopSignal(_: CInt) {
stopRequested = 1
}
private func isStopRequested() -> Bool {
stopRequested != 0 || simulatorStopped != 0
}
func usage() -> String {
"""
Usage:
record-sim --udid <UDID> --output <session-dir> [options]
Options:
--segment-duration <seconds> Target fMP4 media segment duration. Use 0 for one MP4. Default: 120
--fps <fps> Expected source frame rate. Default: 30
--bitrate <bits/sec> AVAssetWriter average bitrate. Default: 30000000
--codec <h264|hevc> Video codec. Default: h264
-h, --help Show this help
"""
}
func parseOptions(_ args: [String]) throws -> CLIOptions {
var options = CLIOptions()
var i = 0
func next(_ flag: String) throws -> String {
i += 1
guard i < args.count else {
throw NSError(domain: "record-sim", code: 2, userInfo: [NSLocalizedDescriptionKey: "Missing value for \(flag)"])
}
return args[i]
}
while i < args.count {
let arg = args[i]
switch arg {
case "--udid", "-u":
options.udid = try next(arg)
case "--output", "--output-dir", "--out", "-o":
options.output = try next(arg)
case "--segment-duration":
options.segmentDuration = Double(try next(arg)) ?? -1
case "--fps":
options.fps = Int(try next(arg)) ?? 0
case "--bitrate":
options.bitrate = Int(try next(arg)) ?? 0
case "--codec":
let raw = try next(arg)
guard let codec = RecorderCodec(rawValue: raw) else {
throw NSError(domain: "record-sim", code: 2, userInfo: [NSLocalizedDescriptionKey: "--codec must be h264 or hevc"])
}
options.codec = codec
case "--help", "-h":
print(usage())
exit(0)
default:
throw NSError(domain: "record-sim", code: 2, userInfo: [NSLocalizedDescriptionKey: "Unknown argument: \(arg)"])
}
i += 1
}
guard options.udid != nil else {
throw NSError(domain: "record-sim", code: 2, userInfo: [NSLocalizedDescriptionKey: "Missing required --udid"])
}
guard options.output != nil else {
throw NSError(domain: "record-sim", code: 2, userInfo: [NSLocalizedDescriptionKey: "Missing required --output"])
}
guard options.segmentDuration >= 0 else {
throw NSError(domain: "record-sim", code: 2, userInfo: [NSLocalizedDescriptionKey: "--segment-duration must be non-negative"])
}
guard options.fps > 0, options.fps <= 120 else {
throw NSError(domain: "record-sim", code: 2, userInfo: [NSLocalizedDescriptionKey: "--fps must be between 1 and 120"])
}
guard options.bitrate > 0 else {
throw NSError(domain: "record-sim", code: 2, userInfo: [NSLocalizedDescriptionKey: "--bitrate must be positive"])
}
return options
}
do {
signal(SIGINT, handleStopSignal)
signal(SIGTERM, handleStopSignal)
let options = try parseOptions(Array(CommandLine.arguments.dropFirst()))
let configuration = SimulatorRecordingConfiguration(
deviceUDID: options.udid!,
outputDirectory: URL(fileURLWithPath: options.output!),
fps: options.fps,
bitrate: options.bitrate,
codec: options.codec,
segmentDuration: options.segmentDuration
)
let recorder = SimulatorRecorder(configuration: configuration)
recorder.onSimulatorStopped = { state in
simulatorStopped = 1
fputs("[record-sim] simulator stopped state=\(state); finalizing recording\n", stderr)
}
recorder.onSegment = { segment in
switch segment.kind {
case .initialization:
fputs("[record-sim] init \(segment.relativePath) bytes=\(segment.byteCount)\n", stderr)
case .media:
let duration = segment.durationSeconds ?? 0
fputs(String(format: "[record-sim] segment %@ duration=%.3fs bytes=%d\n", segment.relativePath, duration, segment.byteCount), stderr)
}
}
try recorder.start()
try recorder.waitUntilFirstFrame()
fputs("[record-sim] recording -> \(options.output!)\n", stderr)
while !isStopRequested() {
Thread.sleep(forTimeInterval: 0.1)
}
let manifest = try recorder.stop()
if let recording = manifest.recording {
fputs("[record-sim] done recording=\(recording)\n", stderr)
} else {
fputs("[record-sim] done segments=\(manifest.segments.count)\n", stderr)
}
fputs("[record-sim] manifest \(URL(fileURLWithPath: options.output!).appendingPathComponent("session.json").path)\n", stderr)
} catch {
fputs("error: \(error.localizedDescription)\n", stderr)
exit(1)
}
@@ -19,10 +19,12 @@ jest.mock('fs-extra');
jest.mock('../datadog', () => ({
Datadog: {
distribution: jest.fn(),
log: jest.fn(),
},
}));
const datadogDistributionMock = jest.mocked(Datadog.distribution);
const datadogLogMock = jest.mocked(Datadog.log);
describe('BuildContext', () => {
beforeEach(() => {
@@ -139,6 +141,77 @@ describe('BuildContext', () => {
});
});
it('overwrites existing environment variables when updating env', async () => {
await vol.promises.mkdir('/workingdir/eas-environment-secrets/', { recursive: true });
const ctx = new BuildContext(
{
triggeredBy: BuildTrigger.GIT_BASED_INTEGRATION,
secrets: {},
} as Job,
{
env: {
__API_SERVER_URL: 'http://api.expo.test',
EXISTING_ENV: 'old-value',
REMOVED_ENV: 'old-value',
},
workingdir: '/workingdir',
logger: createMockLogger(),
logBuffer: { getLogs: () => [], getPhaseLogs: () => [] },
uploadArtifact: jest.fn(),
}
);
ctx.updateEnv({
EXISTING_ENV: 'new-value',
REMOVED_ENV: undefined,
NEW_ENV: 'new-env-value',
});
expect(ctx.env.EXISTING_ENV).toBe('new-value');
expect(ctx.env.REMOVED_ENV).toBeUndefined();
expect(ctx.env.NEW_ENV).toBe('new-env-value');
const logMessage = datadogLogMock.mock.calls[0][0];
expect(logMessage).toContain('BuildContext.updateEnv merge order produced different env');
expect(logMessage).toContain('different_env_names=');
expect(logMessage).toContain('EXISTING_ENV');
expect(logMessage).toContain('REMOVED_ENV');
expect(JSON.stringify(datadogLogMock.mock.calls)).not.toContain('old-value');
expect(JSON.stringify(datadogLogMock.mock.calls)).not.toContain('new-value');
expect(JSON.stringify(datadogLogMock.mock.calls)).not.toContain('new-env-value');
});
it('logs when updateEnv merge order produces the same env without values', async () => {
await vol.promises.mkdir('/workingdir/eas-environment-secrets/', { recursive: true });
const ctx = new BuildContext(
{
triggeredBy: BuildTrigger.GIT_BASED_INTEGRATION,
secrets: {},
} as Job,
{
env: {
__API_SERVER_URL: 'http://api.expo.test',
EXISTING_ENV: 'same-value',
},
workingdir: '/workingdir',
logger: createMockLogger(),
logBuffer: { getLogs: () => [], getPhaseLogs: () => [] },
uploadArtifact: jest.fn(),
}
);
ctx.updateEnv({
EXISTING_ENV: 'same-value',
UNSET_ENV: undefined,
});
expect(datadogLogMock).toHaveBeenCalledWith(
'BuildContext.updateEnv merge order produced same env'
);
expect(JSON.stringify(datadogLogMock.mock.calls)).not.toContain('same-value');
});
it('emits build phase duration metrics for successful build phases', async () => {
const ctx = createTestBuildContext({ platform: Platform.IOS });
+24 -39
View File
@@ -1,12 +1,10 @@
import { Android, Env, Job, Platform } from '@expo/eas-build-job';
import { bunyan } from '@expo/logger';
import spawn, { SpawnPromise, SpawnResult } from '@expo/turtle-spawn';
import assert from 'assert';
import spawn from '@expo/turtle-spawn';
import fs from 'fs-extra';
import path from 'path';
import { BuildContext } from '../context';
import { getParentAndDescendantProcessPidsAsync } from '../utils/processes';
export async function ensureLFLineEndingsInGradlewScript<TJob extends Job>(
ctx: BuildContext<TJob>
@@ -31,10 +29,19 @@ export async function runGradleCommand(
logger.info(`Running 'gradlew ${gradleCommand}' in ${androidDir}`);
await fs.chmod(path.join(androidDir, 'gradlew'), 0o755);
const verboseFlag = ctx.env['EAS_VERBOSE'] === '1' ? '--info' : '';
const shouldResetOOMScore =
ctx.env.EAS_BUILD_RUNNER === 'eas-build' && process.platform === 'linux';
const spawnPromise = spawn(
await spawn(
'bash',
['-c', `./gradlew ${gradleCommand} --profile ${verboseFlag}`],
[
'-c',
getGradleShellCommand({
gradleCommand,
oomScoreAdj: shouldResetOOMScore ? 0 : undefined,
verboseFlag,
}),
],
{
cwd: androidDir,
logger,
@@ -53,42 +60,20 @@ export async function runGradleCommand(
},
}
);
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<SpawnResult>, 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
);
export function getGradleShellCommand({
gradleCommand,
oomScoreAdj,
verboseFlag,
}: {
gradleCommand: string;
oomScoreAdj?: number;
verboseFlag: string;
}): string {
const oomScoreAdjPrefix =
oomScoreAdj === undefined ? '' : `echo ${oomScoreAdj} > /proc/$$/oom_score_adj || true; `;
return `${oomScoreAdjPrefix}exec ./gradlew ${gradleCommand} --profile ${verboseFlag}`;
}
// Version envs should be set at the beginning of the build, but when building
+49 -1
View File
@@ -72,6 +72,41 @@ export interface BuildContextOptions {
export class SkipNativeBuildError extends Error {}
function logEnvComparison({
oldMergeOrderEnv,
newMergeOrderEnv,
}: {
oldMergeOrderEnv: Env;
newMergeOrderEnv: Env;
}): void {
const envNames = new Set([...Object.keys(oldMergeOrderEnv), ...Object.keys(newMergeOrderEnv)]);
const sameEnvNames: string[] = [];
const differentEnvNames: string[] = [];
for (const key of envNames) {
const hasOldValue = key in oldMergeOrderEnv;
const hasNewValue = key in newMergeOrderEnv;
if (hasOldValue === hasNewValue && oldMergeOrderEnv[key] === newMergeOrderEnv[key]) {
sameEnvNames.push(key);
} else {
differentEnvNames.push(key);
}
}
if (differentEnvNames.length === 0) {
Datadog.log('BuildContext.updateEnv merge order produced same env');
return;
}
Datadog.log(
`BuildContext.updateEnv merge order produced different env: compared_env_count=${
envNames.size
} different_env_count=${differentEnvNames.length} different_env_names=${differentEnvNames.join(
','
)} same_env_count=${sameEnvNames.length} same_env_names=${sameEnvNames.join(',')}`
);
}
export class BuildContext<TJob extends Job = Job> {
public readonly workingdir: string;
public logger: bunyan;
@@ -247,11 +282,24 @@ export class BuildContext<TJob extends Job = Job> {
'Updating environment variables is only allowed when build was triggered by a git-based integration.'
);
}
this._env = {
const oldMergeOrderEnv: Env = {
...env,
...this._env,
__EAS_BUILD_ENVS_DIR: this.buildEnvsDirectory,
};
const newMergeOrderEnv: Env = {
...this._env,
...env,
__EAS_BUILD_ENVS_DIR: this.buildEnvsDirectory,
};
logEnvComparison({
oldMergeOrderEnv,
newMergeOrderEnv,
});
this._env = newMergeOrderEnv;
this._env.PATH = this._env.PATH
? [this.buildExecutablesDirectory, this._env.PATH].join(':')
: this.buildExecutablesDirectory;
@@ -16,11 +16,12 @@ import ProvisioningProfile, {
import { BuildContext } from '../../context';
export interface Credentials {
applicationTargetProvisioningProfile: ProvisioningProfile<Ios.Job>;
/** null if not signing */
applicationTargetProvisioningProfile: ProvisioningProfile<Ios.Job> | null;
keychainPath: string;
targetProvisioningProfiles: TargetProvisioningProfiles;
distributionType: DistributionType;
teamId: string;
distributionType: DistributionType | null;
teamId: string | null;
}
export type TargetProvisioningProfiles = Record<string, ProvisioningProfileData>;
@@ -62,17 +63,15 @@ export default class IosCredentialsManager<TJob extends Ios.Job> {
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;
const applicationTargetProvisioningProfile = this.getApplicationTargetProvisioningProfile();
return {
applicationTargetProvisioningProfile,
keychainPath: this.keychain.data.path,
targetProvisioningProfiles,
distributionType,
teamId,
distributionType: applicationTargetProvisioningProfile?.data.distributionType ?? null,
teamId: applicationTargetProvisioningProfile?.data.teamId ?? null,
};
}
@@ -155,9 +154,9 @@ export default class IosCredentialsManager<TJob extends Ios.Job> {
}
}
private getApplicationTargetProvisioningProfile(): ProvisioningProfile<TJob> {
private getApplicationTargetProvisioningProfile(): ProvisioningProfile<TJob> | null {
// sorting works because bundle ids share common prefix
const sorted = orderBy(this.provisioningProfiles, 'data.bundleIdentifier', 'asc');
return sorted[0];
return sorted[0] ?? null;
}
}
+9 -2
View File
@@ -1,4 +1,4 @@
import { Env, Ios } from '@expo/eas-build-job';
import { Env, Ios, UserError } from '@expo/eas-build-job';
import { bunyan } from '@expo/logger';
import spawn, { SpawnResult } from '@expo/turtle-spawn';
import fs from 'fs-extra';
@@ -65,7 +65,14 @@ export async function runFastlaneResign<TJob extends Ios.Job>(
ctx: BuildContext<TJob>,
{ credentials, ipaPath }: { credentials: Credentials; ipaPath: string }
): Promise<void> {
const { certificateCommonName } = credentials.applicationTargetProvisioningProfile.data;
const applicationTargetProvisioningProfile = credentials.applicationTargetProvisioningProfile;
if (!applicationTargetProvisioningProfile) {
throw new UserError(
'MISSING_PROVISIONING_PROFILE',
'Provisioning profile must exist for resigning. Configure credentials with `eas credentials` and try again.'
);
}
const { certificateCommonName } = applicationTargetProvisioningProfile.data;
const fastlaneDirPath = path.join(ctx.buildDirectory, 'fastlane');
await fs.ensureDir(fastlaneDirPath);
@@ -1,132 +0,0 @@
import { BuildStepContext, BuildStepEnv } from '@expo/steps';
import spawn, { SpawnPromise, SpawnResult } from '@expo/turtle-spawn';
import { createMockLogger } from '../../../__tests__/utils/logger';
import {
PackageManager,
findPackagerRootDir,
resolvePackageManager,
shouldUseFrozenLockfile,
} from '../../../utils/packageManager';
import { readPackageJson } from '../../../utils/project';
import { installNodeModules } from '../installNodeModules';
jest.mock('@expo/turtle-spawn', () => jest.fn());
jest.mock('../../../sentry', () => ({
Sentry: {
capture: jest.fn(),
},
}));
jest.mock('../../../utils/packageManager', () => ({
...jest.requireActual('../../../utils/packageManager'),
resolvePackageManager: jest.fn(),
findPackagerRootDir: jest.fn(),
shouldUseFrozenLockfile: jest.fn(),
}));
jest.mock('../../../utils/project', () => ({
...jest.requireActual('../../../utils/project'),
readPackageJson: jest.fn(),
}));
describe(installNodeModules, () => {
beforeEach(() => {
jest.clearAllMocks();
jest.mocked(resolvePackageManager).mockReturnValue(PackageManager.NPM);
jest.mocked(findPackagerRootDir).mockReturnValue('/tmp/build');
jest.mocked(shouldUseFrozenLockfile).mockReturnValue(false);
jest.mocked(readPackageJson).mockReturnValue({});
});
function createStepCtx(logger: ReturnType<typeof createMockLogger>): BuildStepContext {
return {
logger,
workingDirectory: '/tmp/build',
global: {
projectTargetDirectory: '/tmp/build',
staticContext: { metadata: {} },
},
} as unknown as BuildStepContext;
}
it('retries the install without the npm cache registry when the cache install fails', async () => {
const logger = createMockLogger();
const npmCacheUrl = 'http://npm.caches.eas-build.internal';
const env: BuildStepEnv = {
EAS_USE_NPM_CACHE: '1',
EAS_BUILD_NPM_CACHE_URL: npmCacheUrl,
};
const error = Object.assign(new Error('npm install exited with non-zero code: 1'), {
status: 1,
signal: null,
stdout: '',
stderr: `npm error request to ${npmCacheUrl}/left-pad failed, reason: getaddrinfo ENOTFOUND`,
});
jest
.mocked(spawn)
.mockReturnValueOnce(createSpawnPromise(Promise.reject(error)))
.mockReturnValueOnce(createSpawnPromise(Promise.resolve(createSpawnResult())));
await installNodeModules(createStepCtx(logger), env);
expect(spawn).toHaveBeenCalledTimes(2);
expect(spawn).toHaveBeenNthCalledWith(
1,
'npm',
['install', '--include=dev'],
expect.objectContaining({
env: {
EAS_USE_NPM_CACHE: '1',
EAS_BUILD_NPM_CACHE_URL: npmCacheUrl,
NPM_CONFIG_REGISTRY: npmCacheUrl,
},
})
);
expect(spawn).toHaveBeenNthCalledWith(
2,
'npm',
['install', '--include=dev'],
expect.objectContaining({
env: { EAS_USE_NPM_CACHE: '1', EAS_BUILD_NPM_CACHE_URL: npmCacheUrl },
})
);
expect(logger.warn).toHaveBeenCalledWith(
`Failed to install dependencies using the npm cache registry (${npmCacheUrl}). Retrying without the npm cache registry.`
);
});
it('does not retry when the npm cache is not enabled', async () => {
const logger = createMockLogger();
const error = Object.assign(new Error('npm install exited with non-zero code: 1'), {
status: 1,
signal: null,
stdout: '',
stderr: 'npm error some unrelated failure',
});
jest.mocked(spawn).mockReturnValueOnce(createSpawnPromise(Promise.reject(error)));
await expect(installNodeModules(createStepCtx(logger), {})).rejects.toThrow(error);
expect(spawn).toHaveBeenCalledTimes(1);
});
});
function createSpawnPromise(result: Promise<SpawnResult>): SpawnPromise<SpawnResult> {
return Object.assign(result, {
child: {
pid: 123,
},
}) as SpawnPromise<SpawnResult>;
}
function createSpawnResult(): SpawnResult {
return {
pid: 123,
output: ['', ''],
stdout: '',
stderr: '',
status: 0,
signal: null,
};
}
@@ -1,7 +1,7 @@
import { BuildFunction, BuildStepContext, BuildStepEnv } from '@expo/steps';
import path from 'path';
import { installDependenciesWithNpmCacheFallbackAsync } from '../../common/installDependencies';
import { installDependenciesAsync } from '../../common/installDependencies';
import {
findPackagerRootDir,
getPackageVersionFromPackageJson,
@@ -63,7 +63,7 @@ export async function installNodeModules(
packageName: 'react-native',
});
await installDependenciesWithNpmCacheFallbackAsync({
const { spawnPromise } = await installDependenciesAsync({
packageManager,
env,
logger: stepCtx.logger,
@@ -74,4 +74,5 @@ export async function installNodeModules(
reactNativeVersion,
}),
});
await spawnPromise;
}
@@ -14,6 +14,7 @@ import path from 'node:path';
import { type CustomBuildContext } from '../../customBuildContext';
import { Sentry } from '../../sentry';
import { pollAgentDeviceArtifactsForUploadAsync } from '../utils/agentDeviceArtifacts';
import {
type DetachedProcessHandle,
getDeviceRunSessionIdOrThrow,
@@ -28,10 +29,14 @@ import {
} from '../utils/remoteDeviceRunSession';
const AGENT_DEVICE_PACKAGE_NAME = 'agent-device';
const AGENT_DEVICE_REPO_URL = 'https://github.com/callstackincubator/agent-device.git';
const AGENT_DEVICE_REPO_URL = 'https://github.com/callstack/agent-device.git';
const SRC_DIR = '/tmp/agent-device-src';
const DAEMON_JSON_PATH = path.join(os.homedir(), '.agent-device', 'daemon.json');
const STARTUP_TIMEOUT_MS = 60_000;
const AGENT_DEVICE_DAEMON_ENV = {
AGENT_DEVICE_DAEMON_SERVER_MODE: 'http',
AGENT_DEVICE_RETAIN_ARTIFACTS: '1',
};
export function createStartAgentDeviceRemoteSessionBuildFunction(
ctx: CustomBuildContext
@@ -109,6 +114,12 @@ export function createStartAgentDeviceRemoteSessionBuildFunction(
},
logger,
});
void pollAgentDeviceArtifactsForUploadAsync(ctx, {
deviceRunSessionId,
daemonUrl: `http://127.0.0.1:${daemonPort}`,
daemonToken,
logger,
});
logger.info('Remote session is live. Keeping the job alive until the session is stopped.');
// Keep the turtle job alive so the daemon and tunnel stay reachable
@@ -144,7 +155,7 @@ async function startAgentDeviceDaemonAsync({
return spawnDetached({
command: 'node',
args: [daemonPath],
env: { ...env, AGENT_DEVICE_DAEMON_SERVER_MODE: 'http' },
env: { ...env, ...AGENT_DEVICE_DAEMON_ENV },
});
} catch (err) {
const error = err instanceof Error ? err : new Error(String(err));
@@ -201,7 +212,7 @@ async function startAgentDeviceDaemonFromGitAsync({
command: 'bun',
args: ['run', 'src/daemon.ts'],
cwd: SRC_DIR,
env: { ...env, AGENT_DEVICE_DAEMON_SERVER_MODE: 'http' },
env: { ...env, ...AGENT_DEVICE_DAEMON_ENV },
});
}
@@ -0,0 +1,205 @@
import { bunyan } from '@expo/logger';
import fetch from 'node-fetch';
import { Readable } from 'node:stream';
import { CustomBuildContext } from '../../../customBuildContext';
import { uploadDeviceRunSessionArtifactAsync } from '../deviceRunSessionArtifacts';
import {
listAgentDeviceArtifactsAsync,
pollAgentDeviceArtifactsForUploadAsync,
uploadAgentDeviceArtifactAsync,
} from '../agentDeviceArtifacts';
jest.mock('../deviceRunSessionArtifacts');
jest.mock('node-fetch');
const { Response } = jest.requireActual('node-fetch') as typeof import('node-fetch');
async function readStreamAsync(stream: NodeJS.ReadableStream): Promise<void> {
for await (const chunk of stream as Readable) {
void chunk;
}
}
async function flushPromisesAsync(): Promise<void> {
for (let i = 0; i < 10; i++) {
await jest.advanceTimersByTimeAsync(0);
await Promise.resolve();
}
}
function createLoggerMock(): bunyan {
return {
info: jest.fn(),
warn: jest.fn(),
error: jest.fn(),
debug: jest.fn(),
} as unknown as bunyan;
}
describe(listAgentDeviceArtifactsAsync, () => {
beforeEach(() => {
jest.mocked(fetch).mockReset();
});
it('lists agent-device artifacts with bearer auth', async () => {
jest.mocked(fetch).mockResolvedValue(
new Response(
JSON.stringify({
artifacts: [
{
id: 'artifact-id',
filename: 'report.json',
mimeType: 'application/json',
sizeBytes: 123,
createdAt: '2026-07-02T12:00:00.000Z',
expiresAt: '2026-07-02T12:15:00.000Z',
},
],
})
)
);
const artifacts = await listAgentDeviceArtifactsAsync({
daemonUrl: 'http://127.0.0.1:1234',
daemonToken: 'daemon-token',
});
expect(artifacts).toEqual([
{
id: 'artifact-id',
filename: 'report.json',
},
]);
expect(jest.mocked(fetch)).toHaveBeenCalledWith('http://127.0.0.1:1234/artifacts', {
headers: { Authorization: 'Bearer daemon-token' },
});
});
it('reports artifact inventory as unsupported when the daemon does not expose the endpoint', async () => {
jest.mocked(fetch).mockResolvedValue(new Response('Not found', { status: 404 }));
await expect(
listAgentDeviceArtifactsAsync({
daemonUrl: 'http://127.0.0.1:1234',
daemonToken: 'daemon-token',
})
).rejects.toThrow('agent-device daemon does not expose artifact inventory.');
});
});
describe(uploadAgentDeviceArtifactAsync, () => {
beforeEach(() => {
jest.mocked(fetch).mockReset();
jest.mocked(uploadDeviceRunSessionArtifactAsync).mockReset();
});
it('downloads an agent-device artifact and uploads it as a device run session artifact', async () => {
const data = Buffer.from('artifact-data');
const logger = createLoggerMock();
const ctx = {} as unknown as CustomBuildContext;
jest.mocked(fetch).mockResolvedValueOnce(new Response(Readable.from([data])));
jest
.mocked(uploadDeviceRunSessionArtifactAsync)
.mockImplementationOnce(async (_ctx, { stream }) => {
await readStreamAsync(stream);
});
await uploadAgentDeviceArtifactAsync(ctx, {
deviceRunSessionId: 'drs-id',
daemonUrl: 'http://127.0.0.1:1234',
daemonToken: 'daemon-token',
logger,
artifact: {
id: 'artifact-id',
filename: 'report.json',
},
});
expect(jest.mocked(fetch)).toHaveBeenCalledWith('http://127.0.0.1:1234/artifacts/artifact-id', {
headers: { Authorization: 'Bearer daemon-token' },
});
expect(jest.mocked(uploadDeviceRunSessionArtifactAsync)).toHaveBeenCalledWith(ctx, {
deviceRunSessionId: 'drs-id',
artifactId: 'artifact-id',
name: 'report.json (artifact-id)',
filename: 'report.json',
size: data.length,
stream: expect.anything(),
});
});
});
describe(pollAgentDeviceArtifactsForUploadAsync, () => {
beforeEach(() => {
jest.useFakeTimers();
jest.mocked(fetch).mockReset();
jest.mocked(uploadDeviceRunSessionArtifactAsync).mockReset();
});
afterEach(() => {
jest.clearAllTimers();
jest.useRealTimers();
});
it('retries an artifact after a failed upload', async () => {
const data = Buffer.from('artifact-data');
const logger = createLoggerMock();
const ctx = {} as unknown as CustomBuildContext;
const artifact = {
id: 'artifact-id',
filename: 'report.json',
};
const listResponse = () => new Response(JSON.stringify({ artifacts: [artifact] }));
jest
.mocked(fetch)
.mockResolvedValueOnce(listResponse())
.mockResolvedValueOnce(new Response(Readable.from([data])))
.mockResolvedValueOnce(listResponse())
.mockResolvedValueOnce(new Response(Readable.from([data])));
jest
.mocked(uploadDeviceRunSessionArtifactAsync)
.mockRejectedValueOnce(new Error('upload failed'))
.mockImplementationOnce(async (_ctx, { stream }) => {
await readStreamAsync(stream);
});
void pollAgentDeviceArtifactsForUploadAsync(ctx, {
deviceRunSessionId: 'drs-id',
daemonUrl: 'http://127.0.0.1:1234',
daemonToken: 'daemon-token',
logger,
});
await flushPromisesAsync();
expect(jest.mocked(uploadDeviceRunSessionArtifactAsync)).toHaveBeenCalledTimes(1);
await jest.advanceTimersByTimeAsync(5_000);
await flushPromisesAsync();
expect(jest.mocked(uploadDeviceRunSessionArtifactAsync)).toHaveBeenCalledTimes(2);
});
it('stops polling when the daemon does not expose artifact inventory', async () => {
const logger = createLoggerMock();
const ctx = {} as unknown as CustomBuildContext;
jest.mocked(fetch).mockResolvedValueOnce(new Response('Not found', { status: 404 }));
await expect(
pollAgentDeviceArtifactsForUploadAsync(ctx, {
deviceRunSessionId: 'drs-id',
daemonUrl: 'http://127.0.0.1:1234',
daemonToken: 'daemon-token',
logger,
})
).resolves.toBeUndefined();
expect(jest.mocked(fetch)).toHaveBeenCalledTimes(1);
expect(logger.warn).toHaveBeenCalledWith(
'agent-device daemon does not expose artifact inventory; remote session artifact uploads are disabled.'
);
});
});
@@ -0,0 +1,191 @@
import { SystemError } from '@expo/eas-build-job';
import { type bunyan } from '@expo/logger';
import { createReadStream, createWriteStream } from 'node:fs';
import { mkdtemp, rm, stat } from 'node:fs/promises';
import fetch from 'node-fetch';
import os from 'node:os';
import path from 'node:path';
import { pipeline } from 'node:stream/promises';
import { z } from 'zod';
import { CustomBuildContext } from '../../customBuildContext';
import { Sentry } from '../../sentry';
import { formatBytes } from '../../utils/artifacts';
import { sleepAsync } from '../../utils/retry';
import { uploadDeviceRunSessionArtifactAsync } from './deviceRunSessionArtifacts';
const AGENT_DEVICE_ARTIFACT_UPLOAD_POLL_INTERVAL_MS = 5_000;
const AgentDeviceArtifactSchema = z.object({
id: z.string(),
filename: z.string(),
});
const AgentDeviceArtifactsListResponseSchema = z.object({
artifacts: z.array(AgentDeviceArtifactSchema),
});
export type AgentDeviceArtifact = z.infer<typeof AgentDeviceArtifactSchema>;
class AgentDeviceArtifactsUnsupportedError extends SystemError {
constructor() {
super('agent-device daemon does not expose artifact inventory.');
}
}
export async function pollAgentDeviceArtifactsForUploadAsync(
ctx: CustomBuildContext,
{
deviceRunSessionId,
daemonUrl,
daemonToken,
logger,
}: {
deviceRunSessionId: string;
daemonUrl: string;
daemonToken: string;
logger: bunyan;
}
): Promise<void> {
logger.info('Started polling agent-device daemon for artifacts.');
const uploadedArtifactIds = new Set<string>();
let listArtifactsErrorCount = 0;
for (;;) {
try {
const artifacts = await listAgentDeviceArtifactsAsync({ daemonUrl, daemonToken });
listArtifactsErrorCount = 0;
for (const artifact of artifacts) {
if (uploadedArtifactIds.has(artifact.id)) {
continue;
}
try {
await uploadAgentDeviceArtifactAsync(ctx, {
deviceRunSessionId,
daemonUrl,
daemonToken,
artifact,
logger,
});
uploadedArtifactIds.add(artifact.id);
} catch (err) {
const error = err instanceof Error ? err : new Error(String(err));
Sentry.capture('Could not upload agent-device remote session artifact', error);
logger.warn({ err: error }, 'Could not upload agent-device remote session artifact.');
}
}
} catch (err) {
if (err instanceof AgentDeviceArtifactsUnsupportedError) {
logger.warn(
'agent-device daemon does not expose artifact inventory; remote session artifact uploads are disabled.'
);
return;
}
const error = err instanceof Error ? err : new Error(String(err));
listArtifactsErrorCount += 1;
if (listArtifactsErrorCount === 1 || listArtifactsErrorCount % 5 === 0) {
Sentry.capture('Could not list agent-device remote session artifacts', error);
logger.warn(
{ err: error, failedArtifactListCount: listArtifactsErrorCount },
'Could not list agent-device remote session artifacts.'
);
}
}
await sleepAsync(AGENT_DEVICE_ARTIFACT_UPLOAD_POLL_INTERVAL_MS);
}
}
export async function listAgentDeviceArtifactsAsync({
daemonUrl,
daemonToken,
}: {
daemonUrl: string;
daemonToken: string;
}): Promise<AgentDeviceArtifact[]> {
const response = await fetch(new URL('/artifacts', daemonUrl).toString(), {
headers: { Authorization: `Bearer ${daemonToken}` },
});
if (response.status === 404) {
throw new AgentDeviceArtifactsUnsupportedError();
}
if (!response.ok) {
throw new SystemError(
`Failed to list agent-device artifacts: ${response.status} ${response.statusText}`
);
}
const result = AgentDeviceArtifactsListResponseSchema.safeParse(await response.json());
if (!result.success) {
throw new SystemError(`Invalid agent-device artifacts response: ${result.error.message}`);
}
return result.data.artifacts;
}
export async function uploadAgentDeviceArtifactAsync(
ctx: CustomBuildContext,
{
deviceRunSessionId,
daemonUrl,
daemonToken,
artifact,
logger,
}: {
deviceRunSessionId: string;
daemonUrl: string;
daemonToken: string;
artifact: AgentDeviceArtifact;
logger: bunyan;
}
): Promise<void> {
logger.info(`Downloading artifact ${artifact.filename}.`);
const temporaryDirectory = await mkdtemp(path.join(os.tmpdir(), 'agent-device-artifact-'));
try {
const temporaryArtifactPath = path.join(temporaryDirectory, path.basename(artifact.filename));
await downloadAgentDeviceArtifactToFileAsync({
artifact,
daemonUrl,
daemonToken,
destinationPath: temporaryArtifactPath,
});
const { size } = await stat(temporaryArtifactPath);
logger.info(`Uploading artifact ${artifact.filename} (${formatBytes(size)}).`);
await uploadDeviceRunSessionArtifactAsync(ctx, {
deviceRunSessionId,
artifactId: artifact.id,
name: `${artifact.filename} (${artifact.id})`,
filename: artifact.filename,
size,
stream: createReadStream(temporaryArtifactPath),
});
} finally {
await rm(temporaryDirectory, { recursive: true, force: true });
}
}
async function downloadAgentDeviceArtifactToFileAsync({
artifact,
daemonUrl,
daemonToken,
destinationPath,
}: {
artifact: AgentDeviceArtifact;
daemonUrl: string;
daemonToken: string;
destinationPath: string;
}): Promise<void> {
const response = await fetch(
new URL(`/artifacts/${encodeURIComponent(artifact.id)}`, daemonUrl).toString(),
{
headers: { Authorization: `Bearer ${daemonToken}` },
}
);
if (!response.ok) {
throw new SystemError(
`Failed to download agent-device artifact ${artifact.id}: ${response.status} ${response.statusText}`
);
}
if (!response.body) {
throw new SystemError(
`Agent-device artifact ${artifact.id} response did not include a readable body.`
);
}
await pipeline(response.body, createWriteStream(destinationPath));
}
@@ -1,8 +1,7 @@
import { Android } from '@expo/eas-build-job';
import { bunyan } from '@expo/logger';
import { BuildStepEnv } from '@expo/steps';
import spawn, { SpawnPromise, SpawnResult } from '@expo/turtle-spawn';
import assert from 'assert';
import spawn from '@expo/turtle-spawn';
import fs from 'fs-extra';
import path from 'path';
@@ -23,10 +22,18 @@ export async function runGradleCommand({
logger.info(`Running 'gradlew ${gradleCommand} ${verboseFlag}' in ${androidDir}`);
await fs.chmod(path.join(androidDir, 'gradlew'), 0o755);
const shouldResetOOMScore = env.EAS_BUILD_RUNNER === 'eas-build' && process.platform === 'linux';
const spawnPromise = spawn(
await spawn(
'bash',
['-c', `./gradlew ${gradleCommand} --profile ${verboseFlag}`],
[
'-c',
getGradleShellCommand({
gradleCommand,
oomScoreAdj: shouldResetOOMScore ? 0 : undefined,
verboseFlag,
}),
],
{
cwd: androidDir,
logger,
@@ -44,73 +51,20 @@ export async function runGradleCommand({
},
}
);
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<SpawnResult>, 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<number[]> {
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<number[]> {
const children = new Set<number>([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 getGradleShellCommand({
gradleCommand,
oomScoreAdj,
verboseFlag,
}: {
gradleCommand: string;
oomScoreAdj?: number;
verboseFlag: string;
}): string {
const oomScoreAdjPrefix =
oomScoreAdj === undefined ? '' : `echo ${oomScoreAdj} > /proc/$$/oom_score_adj || true; `;
return `${oomScoreAdjPrefix}exec ./gradlew ${gradleCommand} --profile ${verboseFlag}`;
}
export function resolveGradleCommand(job: Android.Job, command?: string): string {
@@ -32,6 +32,7 @@ function zipEntryMap(entries: Record<string, true>): Record<string, { name: stri
function makeCtx(overrides: {
platform: Platform;
simulator?: boolean;
developmentClient?: boolean;
channel?: string;
env?: Record<string, string>;
}): BuildContext<any> {
@@ -40,10 +41,12 @@ function makeCtx(overrides: {
? {
platform: Platform.IOS,
simulator: overrides.simulator ?? false,
developmentClient: overrides.developmentClient ?? false,
updates: overrides.channel ? { channel: overrides.channel } : undefined,
}
: {
platform: Platform.ANDROID,
developmentClient: overrides.developmentClient ?? false,
updates: overrides.channel ? { channel: overrides.channel } : undefined,
};
@@ -192,6 +195,20 @@ describe('uploadEmbeddedBundleAsync', () => {
expect(artifacts.findArtifacts).not.toHaveBeenCalled();
});
it('skips development client builds', async () => {
const ctx = makeCtx({
platform: Platform.ANDROID,
developmentClient: true,
channel: 'development',
});
await uploadEmbeddedBundleAsync(ctx);
expect(ctx.markBuildPhaseSkipped).toHaveBeenCalled();
expect(ctx.markBuildPhaseHasWarnings).not.toHaveBeenCalled();
expect(artifacts.findArtifacts).not.toHaveBeenCalled();
});
it('warns when bundle or manifest is missing from the archive', async () => {
jest.mocked(artifacts.findArtifacts).mockResolvedValue(['/tmp/app-release.apk']);
mockZipEntries.mockResolvedValue(
@@ -18,6 +18,11 @@ export async function uploadEmbeddedBundleAsync(ctx: BuildContext<BuildJob>): Pr
return;
}
if (ctx.job.developmentClient) {
ctx.markBuildPhaseSkipped();
return;
}
const { platform } = ctx.job;
if (platform === Platform.IOS && (ctx.job as Ios.Job).simulator) {
ctx.markBuildPhaseSkipped();
@@ -1,6 +1,6 @@
{
"name": "create-eas-build-function",
"version": "20.1.0",
"version": "20.5.1",
"description": "Create functions for use in EAS Build custom builds.",
"keywords": [
"expo",
@@ -35,7 +35,7 @@
"clean": "rimraf ./build/ \"*.tsbuildinfo\""
},
"dependencies": {
"@expo/steps": "20.1.0"
"@expo/steps": "20.5.1"
},
"devDependencies": {
"@expo/package-manager": "1.9.10",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@expo/eas-build-job",
"version": "20.1.0",
"version": "20.5.1",
"bugs": "https://github.com/expo/eas-cli/issues",
"license": "MIT",
"author": "Expo <[email protected]>",
+114 -114
View File
@@ -207,7 +207,7 @@ DESCRIPTION
view the audit logs for an account
```
_See code: [packages/eas-cli/src/commands/account/audit.ts](https://github.com/expo/eas-cli/blob/v20.5.0/packages/eas-cli/src/commands/account/audit.ts)_
_See code: [packages/eas-cli/src/commands/account/audit.ts](https://github.com/expo/eas-cli/blob/v20.5.1/packages/eas-cli/src/commands/account/audit.ts)_
## `eas account:login`
@@ -228,7 +228,7 @@ ALIASES
$ eas login
```
_See code: [packages/eas-cli/src/commands/account/login.ts](https://github.com/expo/eas-cli/blob/v20.5.0/packages/eas-cli/src/commands/account/login.ts)_
_See code: [packages/eas-cli/src/commands/account/login.ts](https://github.com/expo/eas-cli/blob/v20.5.1/packages/eas-cli/src/commands/account/login.ts)_
## `eas account:logout`
@@ -245,7 +245,7 @@ ALIASES
$ eas logout
```
_See code: [packages/eas-cli/src/commands/account/logout.ts](https://github.com/expo/eas-cli/blob/v20.5.0/packages/eas-cli/src/commands/account/logout.ts)_
_See code: [packages/eas-cli/src/commands/account/logout.ts](https://github.com/expo/eas-cli/blob/v20.5.1/packages/eas-cli/src/commands/account/logout.ts)_
## `eas account:usage [ACCOUNT_NAME]`
@@ -267,7 +267,7 @@ DESCRIPTION
view account usage and billing for the current cycle
```
_See code: [packages/eas-cli/src/commands/account/usage.ts](https://github.com/expo/eas-cli/blob/v20.5.0/packages/eas-cli/src/commands/account/usage.ts)_
_See code: [packages/eas-cli/src/commands/account/usage.ts](https://github.com/expo/eas-cli/blob/v20.5.1/packages/eas-cli/src/commands/account/usage.ts)_
## `eas account:view`
@@ -284,7 +284,7 @@ ALIASES
$ eas whoami
```
_See code: [packages/eas-cli/src/commands/account/view.ts](https://github.com/expo/eas-cli/blob/v20.5.0/packages/eas-cli/src/commands/account/view.ts)_
_See code: [packages/eas-cli/src/commands/account/view.ts](https://github.com/expo/eas-cli/blob/v20.5.1/packages/eas-cli/src/commands/account/view.ts)_
## `eas analytics [STATUS]`
@@ -298,7 +298,7 @@ DESCRIPTION
display or change analytics settings
```
_See code: [packages/eas-cli/src/commands/analytics.ts](https://github.com/expo/eas-cli/blob/v20.5.0/packages/eas-cli/src/commands/analytics.ts)_
_See code: [packages/eas-cli/src/commands/analytics.ts](https://github.com/expo/eas-cli/blob/v20.5.1/packages/eas-cli/src/commands/analytics.ts)_
## `eas autocomplete [SHELL]`
@@ -350,7 +350,7 @@ DESCRIPTION
create a branch
```
_See code: [packages/eas-cli/src/commands/branch/create.ts](https://github.com/expo/eas-cli/blob/v20.5.0/packages/eas-cli/src/commands/branch/create.ts)_
_See code: [packages/eas-cli/src/commands/branch/create.ts](https://github.com/expo/eas-cli/blob/v20.5.1/packages/eas-cli/src/commands/branch/create.ts)_
## `eas branch:delete [NAME]`
@@ -371,7 +371,7 @@ DESCRIPTION
delete a branch
```
_See code: [packages/eas-cli/src/commands/branch/delete.ts](https://github.com/expo/eas-cli/blob/v20.5.0/packages/eas-cli/src/commands/branch/delete.ts)_
_See code: [packages/eas-cli/src/commands/branch/delete.ts](https://github.com/expo/eas-cli/blob/v20.5.1/packages/eas-cli/src/commands/branch/delete.ts)_
## `eas branch:list`
@@ -391,7 +391,7 @@ DESCRIPTION
list all branches
```
_See code: [packages/eas-cli/src/commands/branch/list.ts](https://github.com/expo/eas-cli/blob/v20.5.0/packages/eas-cli/src/commands/branch/list.ts)_
_See code: [packages/eas-cli/src/commands/branch/list.ts](https://github.com/expo/eas-cli/blob/v20.5.1/packages/eas-cli/src/commands/branch/list.ts)_
## `eas branch:rename`
@@ -411,7 +411,7 @@ DESCRIPTION
rename a branch
```
_See code: [packages/eas-cli/src/commands/branch/rename.ts](https://github.com/expo/eas-cli/blob/v20.5.0/packages/eas-cli/src/commands/branch/rename.ts)_
_See code: [packages/eas-cli/src/commands/branch/rename.ts](https://github.com/expo/eas-cli/blob/v20.5.1/packages/eas-cli/src/commands/branch/rename.ts)_
## `eas branch:view [NAME]`
@@ -434,7 +434,7 @@ DESCRIPTION
view a branch
```
_See code: [packages/eas-cli/src/commands/branch/view.ts](https://github.com/expo/eas-cli/blob/v20.5.0/packages/eas-cli/src/commands/branch/view.ts)_
_See code: [packages/eas-cli/src/commands/branch/view.ts](https://github.com/expo/eas-cli/blob/v20.5.1/packages/eas-cli/src/commands/branch/view.ts)_
## `eas build`
@@ -475,7 +475,7 @@ DESCRIPTION
start a build
```
_See code: [packages/eas-cli/src/commands/build/index.ts](https://github.com/expo/eas-cli/blob/v20.5.0/packages/eas-cli/src/commands/build/index.ts)_
_See code: [packages/eas-cli/src/commands/build/index.ts](https://github.com/expo/eas-cli/blob/v20.5.1/packages/eas-cli/src/commands/build/index.ts)_
## `eas build:cancel [BUILD_ID]`
@@ -495,7 +495,7 @@ DESCRIPTION
cancel a build
```
_See code: [packages/eas-cli/src/commands/build/cancel.ts](https://github.com/expo/eas-cli/blob/v20.5.0/packages/eas-cli/src/commands/build/cancel.ts)_
_See code: [packages/eas-cli/src/commands/build/cancel.ts](https://github.com/expo/eas-cli/blob/v20.5.1/packages/eas-cli/src/commands/build/cancel.ts)_
## `eas build:configure`
@@ -513,7 +513,7 @@ DESCRIPTION
configure the project to support EAS Build
```
_See code: [packages/eas-cli/src/commands/build/configure.ts](https://github.com/expo/eas-cli/blob/v20.5.0/packages/eas-cli/src/commands/build/configure.ts)_
_See code: [packages/eas-cli/src/commands/build/configure.ts](https://github.com/expo/eas-cli/blob/v20.5.1/packages/eas-cli/src/commands/build/configure.ts)_
## `eas build:delete [BUILD_ID]`
@@ -533,7 +533,7 @@ DESCRIPTION
delete a build
```
_See code: [packages/eas-cli/src/commands/build/delete.ts](https://github.com/expo/eas-cli/blob/v20.5.0/packages/eas-cli/src/commands/build/delete.ts)_
_See code: [packages/eas-cli/src/commands/build/delete.ts](https://github.com/expo/eas-cli/blob/v20.5.1/packages/eas-cli/src/commands/build/delete.ts)_
## `eas build:dev`
@@ -558,7 +558,7 @@ DESCRIPTION
run dev client simulator/emulator build with matching fingerprint or create a new one
```
_See code: [packages/eas-cli/src/commands/build/dev.ts](https://github.com/expo/eas-cli/blob/v20.5.0/packages/eas-cli/src/commands/build/dev.ts)_
_See code: [packages/eas-cli/src/commands/build/dev.ts](https://github.com/expo/eas-cli/blob/v20.5.1/packages/eas-cli/src/commands/build/dev.ts)_
## `eas build:download`
@@ -585,7 +585,7 @@ DESCRIPTION
download a simulator/emulator build by build ID or fingerprint hash
```
_See code: [packages/eas-cli/src/commands/build/download.ts](https://github.com/expo/eas-cli/blob/v20.5.0/packages/eas-cli/src/commands/build/download.ts)_
_See code: [packages/eas-cli/src/commands/build/download.ts](https://github.com/expo/eas-cli/blob/v20.5.1/packages/eas-cli/src/commands/build/download.ts)_
## `eas build:inspect`
@@ -623,7 +623,7 @@ DESCRIPTION
inspect the state of the project at specific build stages, useful for troubleshooting
```
_See code: [packages/eas-cli/src/commands/build/inspect.ts](https://github.com/expo/eas-cli/blob/v20.5.0/packages/eas-cli/src/commands/build/inspect.ts)_
_See code: [packages/eas-cli/src/commands/build/inspect.ts](https://github.com/expo/eas-cli/blob/v20.5.1/packages/eas-cli/src/commands/build/inspect.ts)_
## `eas build:list`
@@ -663,7 +663,7 @@ DESCRIPTION
list all builds for your project
```
_See code: [packages/eas-cli/src/commands/build/list.ts](https://github.com/expo/eas-cli/blob/v20.5.0/packages/eas-cli/src/commands/build/list.ts)_
_See code: [packages/eas-cli/src/commands/build/list.ts](https://github.com/expo/eas-cli/blob/v20.5.1/packages/eas-cli/src/commands/build/list.ts)_
## `eas build:resign`
@@ -693,7 +693,7 @@ DESCRIPTION
re-sign a build archive
```
_See code: [packages/eas-cli/src/commands/build/resign.ts](https://github.com/expo/eas-cli/blob/v20.5.0/packages/eas-cli/src/commands/build/resign.ts)_
_See code: [packages/eas-cli/src/commands/build/resign.ts](https://github.com/expo/eas-cli/blob/v20.5.1/packages/eas-cli/src/commands/build/resign.ts)_
## `eas build:run`
@@ -721,7 +721,7 @@ DESCRIPTION
run simulator/emulator builds from eas-cli
```
_See code: [packages/eas-cli/src/commands/build/run.ts](https://github.com/expo/eas-cli/blob/v20.5.0/packages/eas-cli/src/commands/build/run.ts)_
_See code: [packages/eas-cli/src/commands/build/run.ts](https://github.com/expo/eas-cli/blob/v20.5.1/packages/eas-cli/src/commands/build/run.ts)_
## `eas build:submit`
@@ -773,7 +773,7 @@ DESCRIPTION
get the latest version from EAS servers
```
_See code: [packages/eas-cli/src/commands/build/version/get.ts](https://github.com/expo/eas-cli/blob/v20.5.0/packages/eas-cli/src/commands/build/version/get.ts)_
_See code: [packages/eas-cli/src/commands/build/version/get.ts](https://github.com/expo/eas-cli/blob/v20.5.1/packages/eas-cli/src/commands/build/version/get.ts)_
## `eas build:version:set`
@@ -791,7 +791,7 @@ DESCRIPTION
update version of an app
```
_See code: [packages/eas-cli/src/commands/build/version/set.ts](https://github.com/expo/eas-cli/blob/v20.5.0/packages/eas-cli/src/commands/build/version/set.ts)_
_See code: [packages/eas-cli/src/commands/build/version/set.ts](https://github.com/expo/eas-cli/blob/v20.5.1/packages/eas-cli/src/commands/build/version/set.ts)_
## `eas build:version:sync`
@@ -809,7 +809,7 @@ DESCRIPTION
update a version in native code with a value stored on EAS servers
```
_See code: [packages/eas-cli/src/commands/build/version/sync.ts](https://github.com/expo/eas-cli/blob/v20.5.0/packages/eas-cli/src/commands/build/version/sync.ts)_
_See code: [packages/eas-cli/src/commands/build/version/sync.ts](https://github.com/expo/eas-cli/blob/v20.5.1/packages/eas-cli/src/commands/build/version/sync.ts)_
## `eas build:view [BUILD_ID]`
@@ -826,7 +826,7 @@ DESCRIPTION
view a build for your project
```
_See code: [packages/eas-cli/src/commands/build/view.ts](https://github.com/expo/eas-cli/blob/v20.5.0/packages/eas-cli/src/commands/build/view.ts)_
_See code: [packages/eas-cli/src/commands/build/view.ts](https://github.com/expo/eas-cli/blob/v20.5.1/packages/eas-cli/src/commands/build/view.ts)_
## `eas channel:create [NAME]`
@@ -847,7 +847,7 @@ DESCRIPTION
create a channel
```
_See code: [packages/eas-cli/src/commands/channel/create.ts](https://github.com/expo/eas-cli/blob/v20.5.0/packages/eas-cli/src/commands/channel/create.ts)_
_See code: [packages/eas-cli/src/commands/channel/create.ts](https://github.com/expo/eas-cli/blob/v20.5.1/packages/eas-cli/src/commands/channel/create.ts)_
## `eas channel:delete [NAME]`
@@ -868,7 +868,7 @@ DESCRIPTION
Delete a channel
```
_See code: [packages/eas-cli/src/commands/channel/delete.ts](https://github.com/expo/eas-cli/blob/v20.5.0/packages/eas-cli/src/commands/channel/delete.ts)_
_See code: [packages/eas-cli/src/commands/channel/delete.ts](https://github.com/expo/eas-cli/blob/v20.5.1/packages/eas-cli/src/commands/channel/delete.ts)_
## `eas channel:edit [NAME]`
@@ -890,7 +890,7 @@ DESCRIPTION
point a channel at a new branch
```
_See code: [packages/eas-cli/src/commands/channel/edit.ts](https://github.com/expo/eas-cli/blob/v20.5.0/packages/eas-cli/src/commands/channel/edit.ts)_
_See code: [packages/eas-cli/src/commands/channel/edit.ts](https://github.com/expo/eas-cli/blob/v20.5.1/packages/eas-cli/src/commands/channel/edit.ts)_
## `eas channel:insights`
@@ -914,7 +914,7 @@ DESCRIPTION
display adoption, crash, and unique-user insights for a channel + runtime version
```
_See code: [packages/eas-cli/src/commands/channel/insights.ts](https://github.com/expo/eas-cli/blob/v20.5.0/packages/eas-cli/src/commands/channel/insights.ts)_
_See code: [packages/eas-cli/src/commands/channel/insights.ts](https://github.com/expo/eas-cli/blob/v20.5.1/packages/eas-cli/src/commands/channel/insights.ts)_
## `eas channel:list`
@@ -934,7 +934,7 @@ DESCRIPTION
list all channels
```
_See code: [packages/eas-cli/src/commands/channel/list.ts](https://github.com/expo/eas-cli/blob/v20.5.0/packages/eas-cli/src/commands/channel/list.ts)_
_See code: [packages/eas-cli/src/commands/channel/list.ts](https://github.com/expo/eas-cli/blob/v20.5.1/packages/eas-cli/src/commands/channel/list.ts)_
## `eas channel:pause [NAME]`
@@ -956,7 +956,7 @@ DESCRIPTION
pause a channel to stop it from sending updates
```
_See code: [packages/eas-cli/src/commands/channel/pause.ts](https://github.com/expo/eas-cli/blob/v20.5.0/packages/eas-cli/src/commands/channel/pause.ts)_
_See code: [packages/eas-cli/src/commands/channel/pause.ts](https://github.com/expo/eas-cli/blob/v20.5.1/packages/eas-cli/src/commands/channel/pause.ts)_
## `eas channel:resume [NAME]`
@@ -978,7 +978,7 @@ DESCRIPTION
resume a channel to start sending updates
```
_See code: [packages/eas-cli/src/commands/channel/resume.ts](https://github.com/expo/eas-cli/blob/v20.5.0/packages/eas-cli/src/commands/channel/resume.ts)_
_See code: [packages/eas-cli/src/commands/channel/resume.ts](https://github.com/expo/eas-cli/blob/v20.5.1/packages/eas-cli/src/commands/channel/resume.ts)_
## `eas channel:rollout [CHANNEL]`
@@ -1013,7 +1013,7 @@ DESCRIPTION
Roll a new branch out on a channel incrementally.
```
_See code: [packages/eas-cli/src/commands/channel/rollout.ts](https://github.com/expo/eas-cli/blob/v20.5.0/packages/eas-cli/src/commands/channel/rollout.ts)_
_See code: [packages/eas-cli/src/commands/channel/rollout.ts](https://github.com/expo/eas-cli/blob/v20.5.1/packages/eas-cli/src/commands/channel/rollout.ts)_
## `eas channel:view [NAME]`
@@ -1036,7 +1036,7 @@ DESCRIPTION
view a channel
```
_See code: [packages/eas-cli/src/commands/channel/view.ts](https://github.com/expo/eas-cli/blob/v20.5.0/packages/eas-cli/src/commands/channel/view.ts)_
_See code: [packages/eas-cli/src/commands/channel/view.ts](https://github.com/expo/eas-cli/blob/v20.5.1/packages/eas-cli/src/commands/channel/view.ts)_
## `eas config`
@@ -1057,7 +1057,7 @@ DESCRIPTION
display project configuration (app.json + eas.json)
```
_See code: [packages/eas-cli/src/commands/config.ts](https://github.com/expo/eas-cli/blob/v20.5.0/packages/eas-cli/src/commands/config.ts)_
_See code: [packages/eas-cli/src/commands/config.ts](https://github.com/expo/eas-cli/blob/v20.5.1/packages/eas-cli/src/commands/config.ts)_
## `eas credentials`
@@ -1074,7 +1074,7 @@ DESCRIPTION
manage credentials
```
_See code: [packages/eas-cli/src/commands/credentials/index.ts](https://github.com/expo/eas-cli/blob/v20.5.0/packages/eas-cli/src/commands/credentials/index.ts)_
_See code: [packages/eas-cli/src/commands/credentials/index.ts](https://github.com/expo/eas-cli/blob/v20.5.1/packages/eas-cli/src/commands/credentials/index.ts)_
## `eas credentials:configure-build`
@@ -1092,7 +1092,7 @@ DESCRIPTION
Set up credentials for building your project.
```
_See code: [packages/eas-cli/src/commands/credentials/configure-build.ts](https://github.com/expo/eas-cli/blob/v20.5.0/packages/eas-cli/src/commands/credentials/configure-build.ts)_
_See code: [packages/eas-cli/src/commands/credentials/configure-build.ts](https://github.com/expo/eas-cli/blob/v20.5.1/packages/eas-cli/src/commands/credentials/configure-build.ts)_
## `eas deploy [options]`
@@ -1121,7 +1121,7 @@ ALIASES
$ eas worker:deploy
```
_See code: [packages/eas-cli/src/commands/deploy/index.ts](https://github.com/expo/eas-cli/blob/v20.5.0/packages/eas-cli/src/commands/deploy/index.ts)_
_See code: [packages/eas-cli/src/commands/deploy/index.ts](https://github.com/expo/eas-cli/blob/v20.5.1/packages/eas-cli/src/commands/deploy/index.ts)_
## `eas deploy:alias`
@@ -1146,7 +1146,7 @@ ALIASES
$ eas deploy:promote
```
_See code: [packages/eas-cli/src/commands/deploy/alias/index.ts](https://github.com/expo/eas-cli/blob/v20.5.0/packages/eas-cli/src/commands/deploy/alias/index.ts)_
_See code: [packages/eas-cli/src/commands/deploy/alias/index.ts](https://github.com/expo/eas-cli/blob/v20.5.1/packages/eas-cli/src/commands/deploy/alias/index.ts)_
## `eas deploy:alias:delete [ALIAS_NAME]`
@@ -1167,7 +1167,7 @@ ALIASES
$ eas worker:alias:delete
```
_See code: [packages/eas-cli/src/commands/deploy/alias/delete.ts](https://github.com/expo/eas-cli/blob/v20.5.0/packages/eas-cli/src/commands/deploy/alias/delete.ts)_
_See code: [packages/eas-cli/src/commands/deploy/alias/delete.ts](https://github.com/expo/eas-cli/blob/v20.5.1/packages/eas-cli/src/commands/deploy/alias/delete.ts)_
## `eas deploy:delete [DEPLOYMENT_ID]`
@@ -1188,7 +1188,7 @@ ALIASES
$ eas worker:delete
```
_See code: [packages/eas-cli/src/commands/deploy/delete.ts](https://github.com/expo/eas-cli/blob/v20.5.0/packages/eas-cli/src/commands/deploy/delete.ts)_
_See code: [packages/eas-cli/src/commands/deploy/delete.ts](https://github.com/expo/eas-cli/blob/v20.5.1/packages/eas-cli/src/commands/deploy/delete.ts)_
## `eas deploy:promote`
@@ -1225,7 +1225,7 @@ DESCRIPTION
register new Apple Devices to use for internal distribution
```
_See code: [packages/eas-cli/src/commands/device/create.ts](https://github.com/expo/eas-cli/blob/v20.5.0/packages/eas-cli/src/commands/device/create.ts)_
_See code: [packages/eas-cli/src/commands/device/create.ts](https://github.com/expo/eas-cli/blob/v20.5.1/packages/eas-cli/src/commands/device/create.ts)_
## `eas device:delete`
@@ -1245,7 +1245,7 @@ DESCRIPTION
remove a registered device from your account
```
_See code: [packages/eas-cli/src/commands/device/delete.ts](https://github.com/expo/eas-cli/blob/v20.5.0/packages/eas-cli/src/commands/device/delete.ts)_
_See code: [packages/eas-cli/src/commands/device/delete.ts](https://github.com/expo/eas-cli/blob/v20.5.1/packages/eas-cli/src/commands/device/delete.ts)_
## `eas device:list`
@@ -1266,7 +1266,7 @@ DESCRIPTION
list all registered devices for your account
```
_See code: [packages/eas-cli/src/commands/device/list.ts](https://github.com/expo/eas-cli/blob/v20.5.0/packages/eas-cli/src/commands/device/list.ts)_
_See code: [packages/eas-cli/src/commands/device/list.ts](https://github.com/expo/eas-cli/blob/v20.5.1/packages/eas-cli/src/commands/device/list.ts)_
## `eas device:rename`
@@ -1287,7 +1287,7 @@ DESCRIPTION
rename a registered device
```
_See code: [packages/eas-cli/src/commands/device/rename.ts](https://github.com/expo/eas-cli/blob/v20.5.0/packages/eas-cli/src/commands/device/rename.ts)_
_See code: [packages/eas-cli/src/commands/device/rename.ts](https://github.com/expo/eas-cli/blob/v20.5.1/packages/eas-cli/src/commands/device/rename.ts)_
## `eas device:view [UDID]`
@@ -1301,7 +1301,7 @@ DESCRIPTION
view a device for your project
```
_See code: [packages/eas-cli/src/commands/device/view.ts](https://github.com/expo/eas-cli/blob/v20.5.0/packages/eas-cli/src/commands/device/view.ts)_
_See code: [packages/eas-cli/src/commands/device/view.ts](https://github.com/expo/eas-cli/blob/v20.5.1/packages/eas-cli/src/commands/device/view.ts)_
## `eas diagnostics`
@@ -1315,7 +1315,7 @@ DESCRIPTION
display environment info
```
_See code: [packages/eas-cli/src/commands/diagnostics.ts](https://github.com/expo/eas-cli/blob/v20.5.0/packages/eas-cli/src/commands/diagnostics.ts)_
_See code: [packages/eas-cli/src/commands/diagnostics.ts](https://github.com/expo/eas-cli/blob/v20.5.1/packages/eas-cli/src/commands/diagnostics.ts)_
## `eas env:create [ENVIRONMENT]`
@@ -1347,7 +1347,7 @@ DESCRIPTION
create an environment variable for the current project or account
```
_See code: [packages/eas-cli/src/commands/env/create.ts](https://github.com/expo/eas-cli/blob/v20.5.0/packages/eas-cli/src/commands/env/create.ts)_
_See code: [packages/eas-cli/src/commands/env/create.ts](https://github.com/expo/eas-cli/blob/v20.5.1/packages/eas-cli/src/commands/env/create.ts)_
## `eas env:delete [ENVIRONMENT]`
@@ -1373,7 +1373,7 @@ DESCRIPTION
delete an environment variable for the current project or account
```
_See code: [packages/eas-cli/src/commands/env/delete.ts](https://github.com/expo/eas-cli/blob/v20.5.0/packages/eas-cli/src/commands/env/delete.ts)_
_See code: [packages/eas-cli/src/commands/env/delete.ts](https://github.com/expo/eas-cli/blob/v20.5.1/packages/eas-cli/src/commands/env/delete.ts)_
## `eas env:exec ENVIRONMENT BASH_COMMAND`
@@ -1395,7 +1395,7 @@ DESCRIPTION
execute a command with environment variables from the selected environment
```
_See code: [packages/eas-cli/src/commands/env/exec.ts](https://github.com/expo/eas-cli/blob/v20.5.0/packages/eas-cli/src/commands/env/exec.ts)_
_See code: [packages/eas-cli/src/commands/env/exec.ts](https://github.com/expo/eas-cli/blob/v20.5.1/packages/eas-cli/src/commands/env/exec.ts)_
## `eas env:get [ENVIRONMENT]`
@@ -1423,7 +1423,7 @@ DESCRIPTION
view an environment variable for the current project or account
```
_See code: [packages/eas-cli/src/commands/env/get.ts](https://github.com/expo/eas-cli/blob/v20.5.0/packages/eas-cli/src/commands/env/get.ts)_
_See code: [packages/eas-cli/src/commands/env/get.ts](https://github.com/expo/eas-cli/blob/v20.5.1/packages/eas-cli/src/commands/env/get.ts)_
## `eas env:list [ENVIRONMENT]`
@@ -1451,7 +1451,7 @@ DESCRIPTION
list environment variables for the current project or account
```
_See code: [packages/eas-cli/src/commands/env/list.ts](https://github.com/expo/eas-cli/blob/v20.5.0/packages/eas-cli/src/commands/env/list.ts)_
_See code: [packages/eas-cli/src/commands/env/list.ts](https://github.com/expo/eas-cli/blob/v20.5.1/packages/eas-cli/src/commands/env/list.ts)_
## `eas env:pull [ENVIRONMENT]`
@@ -1474,7 +1474,7 @@ DESCRIPTION
pull environment variables for the selected environment to .env file
```
_See code: [packages/eas-cli/src/commands/env/pull.ts](https://github.com/expo/eas-cli/blob/v20.5.0/packages/eas-cli/src/commands/env/pull.ts)_
_See code: [packages/eas-cli/src/commands/env/pull.ts](https://github.com/expo/eas-cli/blob/v20.5.1/packages/eas-cli/src/commands/env/pull.ts)_
## `eas env:push [ENVIRONMENT]`
@@ -1496,7 +1496,7 @@ DESCRIPTION
push environment variables from .env file to the selected environment
```
_See code: [packages/eas-cli/src/commands/env/push.ts](https://github.com/expo/eas-cli/blob/v20.5.0/packages/eas-cli/src/commands/env/push.ts)_
_See code: [packages/eas-cli/src/commands/env/push.ts](https://github.com/expo/eas-cli/blob/v20.5.1/packages/eas-cli/src/commands/env/push.ts)_
## `eas env:update [ENVIRONMENT]`
@@ -1530,7 +1530,7 @@ DESCRIPTION
update an environment variable on the current project or account
```
_See code: [packages/eas-cli/src/commands/env/update.ts](https://github.com/expo/eas-cli/blob/v20.5.0/packages/eas-cli/src/commands/env/update.ts)_
_See code: [packages/eas-cli/src/commands/env/update.ts](https://github.com/expo/eas-cli/blob/v20.5.1/packages/eas-cli/src/commands/env/update.ts)_
## `eas fingerprint:compare [HASH1] [HASH2]`
@@ -1574,7 +1574,7 @@ EXAMPLES
$ eas fingerprint:compare <FINGERPRINT-HASH> --update-id <UPDATE-ID> # Compare fingerprint from update against provided fingerprint
```
_See code: [packages/eas-cli/src/commands/fingerprint/compare.ts](https://github.com/expo/eas-cli/blob/v20.5.0/packages/eas-cli/src/commands/fingerprint/compare.ts)_
_See code: [packages/eas-cli/src/commands/fingerprint/compare.ts](https://github.com/expo/eas-cli/blob/v20.5.1/packages/eas-cli/src/commands/fingerprint/compare.ts)_
## `eas fingerprint:generate`
@@ -1605,7 +1605,7 @@ EXAMPLES
$ eas fingerprint:generate --json --non-interactive --platform android # Output fingerprint json to stdout
```
_See code: [packages/eas-cli/src/commands/fingerprint/generate.ts](https://github.com/expo/eas-cli/blob/v20.5.0/packages/eas-cli/src/commands/fingerprint/generate.ts)_
_See code: [packages/eas-cli/src/commands/fingerprint/generate.ts](https://github.com/expo/eas-cli/blob/v20.5.1/packages/eas-cli/src/commands/fingerprint/generate.ts)_
## `eas help [COMMAND]`
@@ -1684,7 +1684,7 @@ DESCRIPTION
connect a project to an App Store Connect app
```
_See code: [packages/eas-cli/src/commands/integrations/asc/connect.ts](https://github.com/expo/eas-cli/blob/v20.5.0/packages/eas-cli/src/commands/integrations/asc/connect.ts)_
_See code: [packages/eas-cli/src/commands/integrations/asc/connect.ts](https://github.com/expo/eas-cli/blob/v20.5.1/packages/eas-cli/src/commands/integrations/asc/connect.ts)_
## `eas integrations:asc:disconnect`
@@ -1703,7 +1703,7 @@ DESCRIPTION
disconnect the current project from its App Store Connect app
```
_See code: [packages/eas-cli/src/commands/integrations/asc/disconnect.ts](https://github.com/expo/eas-cli/blob/v20.5.0/packages/eas-cli/src/commands/integrations/asc/disconnect.ts)_
_See code: [packages/eas-cli/src/commands/integrations/asc/disconnect.ts](https://github.com/expo/eas-cli/blob/v20.5.1/packages/eas-cli/src/commands/integrations/asc/disconnect.ts)_
## `eas integrations:asc:status`
@@ -1721,7 +1721,7 @@ DESCRIPTION
show the App Store Connect app link status for the current project
```
_See code: [packages/eas-cli/src/commands/integrations/asc/status.ts](https://github.com/expo/eas-cli/blob/v20.5.0/packages/eas-cli/src/commands/integrations/asc/status.ts)_
_See code: [packages/eas-cli/src/commands/integrations/asc/status.ts](https://github.com/expo/eas-cli/blob/v20.5.1/packages/eas-cli/src/commands/integrations/asc/status.ts)_
## `eas integrations:convex:connect`
@@ -1743,7 +1743,7 @@ DESCRIPTION
connect Convex to your Expo project
```
_See code: [packages/eas-cli/src/commands/integrations/convex/connect.ts](https://github.com/expo/eas-cli/blob/v20.5.0/packages/eas-cli/src/commands/integrations/convex/connect.ts)_
_See code: [packages/eas-cli/src/commands/integrations/convex/connect.ts](https://github.com/expo/eas-cli/blob/v20.5.1/packages/eas-cli/src/commands/integrations/convex/connect.ts)_
## `eas integrations:convex:dashboard`
@@ -1757,7 +1757,7 @@ DESCRIPTION
open the Convex dashboard for the linked Convex project
```
_See code: [packages/eas-cli/src/commands/integrations/convex/dashboard.ts](https://github.com/expo/eas-cli/blob/v20.5.0/packages/eas-cli/src/commands/integrations/convex/dashboard.ts)_
_See code: [packages/eas-cli/src/commands/integrations/convex/dashboard.ts](https://github.com/expo/eas-cli/blob/v20.5.1/packages/eas-cli/src/commands/integrations/convex/dashboard.ts)_
## `eas integrations:convex:project`
@@ -1771,7 +1771,7 @@ DESCRIPTION
display the Convex project linked to the current Expo app
```
_See code: [packages/eas-cli/src/commands/integrations/convex/project.ts](https://github.com/expo/eas-cli/blob/v20.5.0/packages/eas-cli/src/commands/integrations/convex/project.ts)_
_See code: [packages/eas-cli/src/commands/integrations/convex/project.ts](https://github.com/expo/eas-cli/blob/v20.5.1/packages/eas-cli/src/commands/integrations/convex/project.ts)_
## `eas integrations:convex:project:delete`
@@ -1789,7 +1789,7 @@ DESCRIPTION
remove the Convex project link for the current Expo app from EAS servers
```
_See code: [packages/eas-cli/src/commands/integrations/convex/project/delete.ts](https://github.com/expo/eas-cli/blob/v20.5.0/packages/eas-cli/src/commands/integrations/convex/project/delete.ts)_
_See code: [packages/eas-cli/src/commands/integrations/convex/project/delete.ts](https://github.com/expo/eas-cli/blob/v20.5.1/packages/eas-cli/src/commands/integrations/convex/project/delete.ts)_
## `eas integrations:convex:team`
@@ -1803,7 +1803,7 @@ DESCRIPTION
display Convex teams linked to the current Expo app's owner account
```
_See code: [packages/eas-cli/src/commands/integrations/convex/team.ts](https://github.com/expo/eas-cli/blob/v20.5.0/packages/eas-cli/src/commands/integrations/convex/team.ts)_
_See code: [packages/eas-cli/src/commands/integrations/convex/team.ts](https://github.com/expo/eas-cli/blob/v20.5.1/packages/eas-cli/src/commands/integrations/convex/team.ts)_
## `eas integrations:convex:team:delete [CONVEX_TEAM]`
@@ -1824,7 +1824,7 @@ DESCRIPTION
remove a Convex team link from the current Expo app owner account's EAS servers
```
_See code: [packages/eas-cli/src/commands/integrations/convex/team/delete.ts](https://github.com/expo/eas-cli/blob/v20.5.0/packages/eas-cli/src/commands/integrations/convex/team/delete.ts)_
_See code: [packages/eas-cli/src/commands/integrations/convex/team/delete.ts](https://github.com/expo/eas-cli/blob/v20.5.1/packages/eas-cli/src/commands/integrations/convex/team/delete.ts)_
## `eas integrations:convex:team:invite [CONVEX_TEAM]`
@@ -1844,7 +1844,7 @@ DESCRIPTION
send a Convex team invitation to your verified email address
```
_See code: [packages/eas-cli/src/commands/integrations/convex/team/invite.ts](https://github.com/expo/eas-cli/blob/v20.5.0/packages/eas-cli/src/commands/integrations/convex/team/invite.ts)_
_See code: [packages/eas-cli/src/commands/integrations/convex/team/invite.ts](https://github.com/expo/eas-cli/blob/v20.5.1/packages/eas-cli/src/commands/integrations/convex/team/invite.ts)_
## `eas integrations:posthog:connect`
@@ -1871,7 +1871,7 @@ DESCRIPTION
connect PostHog to your Expo project
```
_See code: [packages/eas-cli/src/commands/integrations/posthog/connect.ts](https://github.com/expo/eas-cli/blob/v20.5.0/packages/eas-cli/src/commands/integrations/posthog/connect.ts)_
_See code: [packages/eas-cli/src/commands/integrations/posthog/connect.ts](https://github.com/expo/eas-cli/blob/v20.5.1/packages/eas-cli/src/commands/integrations/posthog/connect.ts)_
## `eas integrations:posthog:dashboard`
@@ -1889,7 +1889,7 @@ DESCRIPTION
open the PostHog dashboard for the linked PostHog project
```
_See code: [packages/eas-cli/src/commands/integrations/posthog/dashboard.ts](https://github.com/expo/eas-cli/blob/v20.5.0/packages/eas-cli/src/commands/integrations/posthog/dashboard.ts)_
_See code: [packages/eas-cli/src/commands/integrations/posthog/dashboard.ts](https://github.com/expo/eas-cli/blob/v20.5.1/packages/eas-cli/src/commands/integrations/posthog/dashboard.ts)_
## `eas integrations:posthog:disconnect`
@@ -1908,7 +1908,7 @@ DESCRIPTION
remove the PostHog project link for the current Expo app from EAS servers
```
_See code: [packages/eas-cli/src/commands/integrations/posthog/disconnect.ts](https://github.com/expo/eas-cli/blob/v20.5.0/packages/eas-cli/src/commands/integrations/posthog/disconnect.ts)_
_See code: [packages/eas-cli/src/commands/integrations/posthog/disconnect.ts](https://github.com/expo/eas-cli/blob/v20.5.1/packages/eas-cli/src/commands/integrations/posthog/disconnect.ts)_
## `eas login`
@@ -1960,7 +1960,7 @@ DESCRIPTION
validate the local store configuration
```
_See code: [packages/eas-cli/src/commands/metadata/lint.ts](https://github.com/expo/eas-cli/blob/v20.5.0/packages/eas-cli/src/commands/metadata/lint.ts)_
_See code: [packages/eas-cli/src/commands/metadata/lint.ts](https://github.com/expo/eas-cli/blob/v20.5.1/packages/eas-cli/src/commands/metadata/lint.ts)_
## `eas metadata:pull`
@@ -1978,7 +1978,7 @@ DESCRIPTION
generate the local store configuration from the app stores
```
_See code: [packages/eas-cli/src/commands/metadata/pull.ts](https://github.com/expo/eas-cli/blob/v20.5.0/packages/eas-cli/src/commands/metadata/pull.ts)_
_See code: [packages/eas-cli/src/commands/metadata/pull.ts](https://github.com/expo/eas-cli/blob/v20.5.1/packages/eas-cli/src/commands/metadata/pull.ts)_
## `eas metadata:push`
@@ -1996,7 +1996,7 @@ DESCRIPTION
sync the local store configuration to the app stores
```
_See code: [packages/eas-cli/src/commands/metadata/push.ts](https://github.com/expo/eas-cli/blob/v20.5.0/packages/eas-cli/src/commands/metadata/push.ts)_
_See code: [packages/eas-cli/src/commands/metadata/push.ts](https://github.com/expo/eas-cli/blob/v20.5.1/packages/eas-cli/src/commands/metadata/push.ts)_
## `eas new [PATH]`
@@ -2055,7 +2055,7 @@ DESCRIPTION
arguments, a list of the available event names and associated event counts is returned.
```
_See code: [packages/eas-cli/src/commands/observe/events.ts](https://github.com/expo/eas-cli/blob/v20.5.0/packages/eas-cli/src/commands/observe/events.ts)_
_See code: [packages/eas-cli/src/commands/observe/events.ts](https://github.com/expo/eas-cli/blob/v20.5.1/packages/eas-cli/src/commands/observe/events.ts)_
## `eas observe:metrics [METRIC]`
@@ -2090,7 +2090,7 @@ DESCRIPTION
display individual performance metric samples ordered by value
```
_See code: [packages/eas-cli/src/commands/observe/metrics.ts](https://github.com/expo/eas-cli/blob/v20.5.0/packages/eas-cli/src/commands/observe/metrics.ts)_
_See code: [packages/eas-cli/src/commands/observe/metrics.ts](https://github.com/expo/eas-cli/blob/v20.5.1/packages/eas-cli/src/commands/observe/metrics.ts)_
## `eas observe:metrics-summary`
@@ -2121,7 +2121,7 @@ DESCRIPTION
display aggregated performance metric statistics grouped by app version
```
_See code: [packages/eas-cli/src/commands/observe/metrics-summary.ts](https://github.com/expo/eas-cli/blob/v20.5.0/packages/eas-cli/src/commands/observe/metrics-summary.ts)_
_See code: [packages/eas-cli/src/commands/observe/metrics-summary.ts](https://github.com/expo/eas-cli/blob/v20.5.1/packages/eas-cli/src/commands/observe/metrics-summary.ts)_
## `eas observe:routes`
@@ -2158,7 +2158,7 @@ DESCRIPTION
display app navigation route metrics (Cold TTR, Warm TTR, TTI) grouped by route name
```
_See code: [packages/eas-cli/src/commands/observe/routes.ts](https://github.com/expo/eas-cli/blob/v20.5.0/packages/eas-cli/src/commands/observe/routes.ts)_
_See code: [packages/eas-cli/src/commands/observe/routes.ts](https://github.com/expo/eas-cli/blob/v20.5.1/packages/eas-cli/src/commands/observe/routes.ts)_
## `eas observe:session SESSIONID`
@@ -2180,7 +2180,7 @@ DESCRIPTION
display the timeline of metric and log events for a specific session
```
_See code: [packages/eas-cli/src/commands/observe/session.ts](https://github.com/expo/eas-cli/blob/v20.5.0/packages/eas-cli/src/commands/observe/session.ts)_
_See code: [packages/eas-cli/src/commands/observe/session.ts](https://github.com/expo/eas-cli/blob/v20.5.1/packages/eas-cli/src/commands/observe/session.ts)_
## `eas observe:versions`
@@ -2205,7 +2205,7 @@ DESCRIPTION
display app versions with build and update details
```
_See code: [packages/eas-cli/src/commands/observe/versions.ts](https://github.com/expo/eas-cli/blob/v20.5.0/packages/eas-cli/src/commands/observe/versions.ts)_
_See code: [packages/eas-cli/src/commands/observe/versions.ts](https://github.com/expo/eas-cli/blob/v20.5.1/packages/eas-cli/src/commands/observe/versions.ts)_
## `eas onboarding [TARGET_PROJECT_DIRECTORY]`
@@ -2235,7 +2235,7 @@ DESCRIPTION
open the project page in a web browser
```
_See code: [packages/eas-cli/src/commands/open.ts](https://github.com/expo/eas-cli/blob/v20.5.0/packages/eas-cli/src/commands/open.ts)_
_See code: [packages/eas-cli/src/commands/open.ts](https://github.com/expo/eas-cli/blob/v20.5.1/packages/eas-cli/src/commands/open.ts)_
## `eas project:info`
@@ -2249,7 +2249,7 @@ DESCRIPTION
information about the current project
```
_See code: [packages/eas-cli/src/commands/project/info.ts](https://github.com/expo/eas-cli/blob/v20.5.0/packages/eas-cli/src/commands/project/info.ts)_
_See code: [packages/eas-cli/src/commands/project/info.ts](https://github.com/expo/eas-cli/blob/v20.5.1/packages/eas-cli/src/commands/project/info.ts)_
## `eas project:init`
@@ -2272,7 +2272,7 @@ ALIASES
$ eas init
```
_See code: [packages/eas-cli/src/commands/project/init.ts](https://github.com/expo/eas-cli/blob/v20.5.0/packages/eas-cli/src/commands/project/init.ts)_
_See code: [packages/eas-cli/src/commands/project/init.ts](https://github.com/expo/eas-cli/blob/v20.5.1/packages/eas-cli/src/commands/project/init.ts)_
## `eas project:new [PATH]`
@@ -2296,7 +2296,7 @@ ALIASES
$ eas new
```
_See code: [packages/eas-cli/src/commands/project/new.ts](https://github.com/expo/eas-cli/blob/v20.5.0/packages/eas-cli/src/commands/project/new.ts)_
_See code: [packages/eas-cli/src/commands/project/new.ts](https://github.com/expo/eas-cli/blob/v20.5.1/packages/eas-cli/src/commands/project/new.ts)_
## `eas project:onboarding [TARGET_PROJECT_DIRECTORY]`
@@ -2314,7 +2314,7 @@ ALIASES
$ eas onboarding
```
_See code: [packages/eas-cli/src/commands/project/onboarding.ts](https://github.com/expo/eas-cli/blob/v20.5.0/packages/eas-cli/src/commands/project/onboarding.ts)_
_See code: [packages/eas-cli/src/commands/project/onboarding.ts](https://github.com/expo/eas-cli/blob/v20.5.1/packages/eas-cli/src/commands/project/onboarding.ts)_
## `eas submit`
@@ -2347,7 +2347,7 @@ ALIASES
$ eas build:submit
```
_See code: [packages/eas-cli/src/commands/submit.ts](https://github.com/expo/eas-cli/blob/v20.5.0/packages/eas-cli/src/commands/submit.ts)_
_See code: [packages/eas-cli/src/commands/submit.ts](https://github.com/expo/eas-cli/blob/v20.5.1/packages/eas-cli/src/commands/submit.ts)_
## `eas update`
@@ -2390,7 +2390,7 @@ DESCRIPTION
publish an update group
```
_See code: [packages/eas-cli/src/commands/update/index.ts](https://github.com/expo/eas-cli/blob/v20.5.0/packages/eas-cli/src/commands/update/index.ts)_
_See code: [packages/eas-cli/src/commands/update/index.ts](https://github.com/expo/eas-cli/blob/v20.5.1/packages/eas-cli/src/commands/update/index.ts)_
## `eas update:configure`
@@ -2411,7 +2411,7 @@ DESCRIPTION
configure the project to support EAS Update
```
_See code: [packages/eas-cli/src/commands/update/configure.ts](https://github.com/expo/eas-cli/blob/v20.5.0/packages/eas-cli/src/commands/update/configure.ts)_
_See code: [packages/eas-cli/src/commands/update/configure.ts](https://github.com/expo/eas-cli/blob/v20.5.1/packages/eas-cli/src/commands/update/configure.ts)_
## `eas update:delete GROUPID`
@@ -2432,7 +2432,7 @@ DESCRIPTION
delete all the updates in an update group
```
_See code: [packages/eas-cli/src/commands/update/delete.ts](https://github.com/expo/eas-cli/blob/v20.5.0/packages/eas-cli/src/commands/update/delete.ts)_
_See code: [packages/eas-cli/src/commands/update/delete.ts](https://github.com/expo/eas-cli/blob/v20.5.1/packages/eas-cli/src/commands/update/delete.ts)_
## `eas update:edit [GROUPID]`
@@ -2457,7 +2457,7 @@ DESCRIPTION
edit all the updates in an update group
```
_See code: [packages/eas-cli/src/commands/update/edit.ts](https://github.com/expo/eas-cli/blob/v20.5.0/packages/eas-cli/src/commands/update/edit.ts)_
_See code: [packages/eas-cli/src/commands/update/edit.ts](https://github.com/expo/eas-cli/blob/v20.5.1/packages/eas-cli/src/commands/update/edit.ts)_
## `eas update:embedded:delete ID`
@@ -2478,7 +2478,7 @@ DESCRIPTION
delete an embedded update registered with EAS Update
```
_See code: [packages/eas-cli/src/commands/update/embedded/delete.ts](https://github.com/expo/eas-cli/blob/v20.5.0/packages/eas-cli/src/commands/update/embedded/delete.ts)_
_See code: [packages/eas-cli/src/commands/update/embedded/delete.ts](https://github.com/expo/eas-cli/blob/v20.5.1/packages/eas-cli/src/commands/update/embedded/delete.ts)_
## `eas update:embedded:list`
@@ -2504,7 +2504,7 @@ DESCRIPTION
list embedded updates registered with EAS Update for this project
```
_See code: [packages/eas-cli/src/commands/update/embedded/list.ts](https://github.com/expo/eas-cli/blob/v20.5.0/packages/eas-cli/src/commands/update/embedded/list.ts)_
_See code: [packages/eas-cli/src/commands/update/embedded/list.ts](https://github.com/expo/eas-cli/blob/v20.5.1/packages/eas-cli/src/commands/update/embedded/list.ts)_
## `eas update:embedded:upload`
@@ -2534,7 +2534,7 @@ EXAMPLES
$ eas update:embedded:upload --platform android --bundle android/app/src/main/assets/index.android.bundle --manifest android/app/src/main/assets/app.manifest --channel production --build-id <BUILD-ID>
```
_See code: [packages/eas-cli/src/commands/update/embedded/upload.ts](https://github.com/expo/eas-cli/blob/v20.5.0/packages/eas-cli/src/commands/update/embedded/upload.ts)_
_See code: [packages/eas-cli/src/commands/update/embedded/upload.ts](https://github.com/expo/eas-cli/blob/v20.5.1/packages/eas-cli/src/commands/update/embedded/upload.ts)_
## `eas update:embedded:view ID`
@@ -2554,7 +2554,7 @@ DESCRIPTION
view details of an embedded update registered with EAS Update
```
_See code: [packages/eas-cli/src/commands/update/embedded/view.ts](https://github.com/expo/eas-cli/blob/v20.5.0/packages/eas-cli/src/commands/update/embedded/view.ts)_
_See code: [packages/eas-cli/src/commands/update/embedded/view.ts](https://github.com/expo/eas-cli/blob/v20.5.1/packages/eas-cli/src/commands/update/embedded/view.ts)_
## `eas update:insights GROUPID`
@@ -2581,7 +2581,7 @@ DESCRIPTION
display launch, crash, unique-user, and size insights for an update group
```
_See code: [packages/eas-cli/src/commands/update/insights.ts](https://github.com/expo/eas-cli/blob/v20.5.0/packages/eas-cli/src/commands/update/insights.ts)_
_See code: [packages/eas-cli/src/commands/update/insights.ts](https://github.com/expo/eas-cli/blob/v20.5.1/packages/eas-cli/src/commands/update/insights.ts)_
## `eas update:list`
@@ -2608,7 +2608,7 @@ DESCRIPTION
view the recent updates
```
_See code: [packages/eas-cli/src/commands/update/list.ts](https://github.com/expo/eas-cli/blob/v20.5.0/packages/eas-cli/src/commands/update/list.ts)_
_See code: [packages/eas-cli/src/commands/update/list.ts](https://github.com/expo/eas-cli/blob/v20.5.1/packages/eas-cli/src/commands/update/list.ts)_
## `eas update:republish`
@@ -2646,7 +2646,7 @@ DESCRIPTION
roll back to an existing update
```
_See code: [packages/eas-cli/src/commands/update/republish.ts](https://github.com/expo/eas-cli/blob/v20.5.0/packages/eas-cli/src/commands/update/republish.ts)_
_See code: [packages/eas-cli/src/commands/update/republish.ts](https://github.com/expo/eas-cli/blob/v20.5.1/packages/eas-cli/src/commands/update/republish.ts)_
## `eas update:revert-update-rollout`
@@ -2674,7 +2674,7 @@ DESCRIPTION
revert a rollout update for a project
```
_See code: [packages/eas-cli/src/commands/update/revert-update-rollout.ts](https://github.com/expo/eas-cli/blob/v20.5.0/packages/eas-cli/src/commands/update/revert-update-rollout.ts)_
_See code: [packages/eas-cli/src/commands/update/revert-update-rollout.ts](https://github.com/expo/eas-cli/blob/v20.5.1/packages/eas-cli/src/commands/update/revert-update-rollout.ts)_
## `eas update:roll-back-to-embedded`
@@ -2704,7 +2704,7 @@ DESCRIPTION
roll back to the embedded update
```
_See code: [packages/eas-cli/src/commands/update/roll-back-to-embedded.ts](https://github.com/expo/eas-cli/blob/v20.5.0/packages/eas-cli/src/commands/update/roll-back-to-embedded.ts)_
_See code: [packages/eas-cli/src/commands/update/roll-back-to-embedded.ts](https://github.com/expo/eas-cli/blob/v20.5.1/packages/eas-cli/src/commands/update/roll-back-to-embedded.ts)_
## `eas update:rollback [GROUPID]`
@@ -2736,7 +2736,7 @@ DESCRIPTION
roll back to an embedded update or an existing update
```
_See code: [packages/eas-cli/src/commands/update/rollback.ts](https://github.com/expo/eas-cli/blob/v20.5.0/packages/eas-cli/src/commands/update/rollback.ts)_
_See code: [packages/eas-cli/src/commands/update/rollback.ts](https://github.com/expo/eas-cli/blob/v20.5.1/packages/eas-cli/src/commands/update/rollback.ts)_
## `eas update:view GROUPID`
@@ -2760,7 +2760,7 @@ DESCRIPTION
update group details
```
_See code: [packages/eas-cli/src/commands/update/view.ts](https://github.com/expo/eas-cli/blob/v20.5.0/packages/eas-cli/src/commands/update/view.ts)_
_See code: [packages/eas-cli/src/commands/update/view.ts](https://github.com/expo/eas-cli/blob/v20.5.1/packages/eas-cli/src/commands/update/view.ts)_
## `eas upload`
@@ -2781,7 +2781,7 @@ DESCRIPTION
upload a local build and generate a sharable link
```
_See code: [packages/eas-cli/src/commands/upload.ts](https://github.com/expo/eas-cli/blob/v20.5.0/packages/eas-cli/src/commands/upload.ts)_
_See code: [packages/eas-cli/src/commands/upload.ts](https://github.com/expo/eas-cli/blob/v20.5.1/packages/eas-cli/src/commands/upload.ts)_
## `eas webhook:create`
@@ -2803,7 +2803,7 @@ DESCRIPTION
create a webhook
```
_See code: [packages/eas-cli/src/commands/webhook/create.ts](https://github.com/expo/eas-cli/blob/v20.5.0/packages/eas-cli/src/commands/webhook/create.ts)_
_See code: [packages/eas-cli/src/commands/webhook/create.ts](https://github.com/expo/eas-cli/blob/v20.5.1/packages/eas-cli/src/commands/webhook/create.ts)_
## `eas webhook:delete [ID]`
@@ -2823,7 +2823,7 @@ DESCRIPTION
delete a webhook
```
_See code: [packages/eas-cli/src/commands/webhook/delete.ts](https://github.com/expo/eas-cli/blob/v20.5.0/packages/eas-cli/src/commands/webhook/delete.ts)_
_See code: [packages/eas-cli/src/commands/webhook/delete.ts](https://github.com/expo/eas-cli/blob/v20.5.1/packages/eas-cli/src/commands/webhook/delete.ts)_
## `eas webhook:list`
@@ -2842,7 +2842,7 @@ DESCRIPTION
list webhooks
```
_See code: [packages/eas-cli/src/commands/webhook/list.ts](https://github.com/expo/eas-cli/blob/v20.5.0/packages/eas-cli/src/commands/webhook/list.ts)_
_See code: [packages/eas-cli/src/commands/webhook/list.ts](https://github.com/expo/eas-cli/blob/v20.5.1/packages/eas-cli/src/commands/webhook/list.ts)_
## `eas webhook:update`
@@ -2865,7 +2865,7 @@ DESCRIPTION
update a webhook
```
_See code: [packages/eas-cli/src/commands/webhook/update.ts](https://github.com/expo/eas-cli/blob/v20.5.0/packages/eas-cli/src/commands/webhook/update.ts)_
_See code: [packages/eas-cli/src/commands/webhook/update.ts](https://github.com/expo/eas-cli/blob/v20.5.1/packages/eas-cli/src/commands/webhook/update.ts)_
## `eas webhook:view ID`
@@ -2882,7 +2882,7 @@ DESCRIPTION
view a webhook
```
_See code: [packages/eas-cli/src/commands/webhook/view.ts](https://github.com/expo/eas-cli/blob/v20.5.0/packages/eas-cli/src/commands/webhook/view.ts)_
_See code: [packages/eas-cli/src/commands/webhook/view.ts](https://github.com/expo/eas-cli/blob/v20.5.1/packages/eas-cli/src/commands/webhook/view.ts)_
## `eas whoami`
@@ -3003,7 +3003,7 @@ DESCRIPTION
to cancel.
```
_See code: [packages/eas-cli/src/commands/workflow/cancel.ts](https://github.com/expo/eas-cli/blob/v20.5.0/packages/eas-cli/src/commands/workflow/cancel.ts)_
_See code: [packages/eas-cli/src/commands/workflow/cancel.ts](https://github.com/expo/eas-cli/blob/v20.5.1/packages/eas-cli/src/commands/workflow/cancel.ts)_
## `eas workflow:create [NAME]`
@@ -3023,7 +3023,7 @@ DESCRIPTION
create a new workflow configuration YAML file
```
_See code: [packages/eas-cli/src/commands/workflow/create.ts](https://github.com/expo/eas-cli/blob/v20.5.0/packages/eas-cli/src/commands/workflow/create.ts)_
_See code: [packages/eas-cli/src/commands/workflow/create.ts](https://github.com/expo/eas-cli/blob/v20.5.1/packages/eas-cli/src/commands/workflow/create.ts)_
## `eas workflow:logs [ID]`
@@ -3047,7 +3047,7 @@ DESCRIPTION
If no ID is passed in, you will be prompted to select from recent workflow runs for the current project.
```
_See code: [packages/eas-cli/src/commands/workflow/logs.ts](https://github.com/expo/eas-cli/blob/v20.5.0/packages/eas-cli/src/commands/workflow/logs.ts)_
_See code: [packages/eas-cli/src/commands/workflow/logs.ts](https://github.com/expo/eas-cli/blob/v20.5.1/packages/eas-cli/src/commands/workflow/logs.ts)_
## `eas workflow:run FILE`
@@ -3087,7 +3087,7 @@ FLAG DESCRIPTIONS
Exit codes: 0 = success, 11 = failure, 12 = canceled, 13 = wait aborted.
```
_See code: [packages/eas-cli/src/commands/workflow/run.ts](https://github.com/expo/eas-cli/blob/v20.5.0/packages/eas-cli/src/commands/workflow/run.ts)_
_See code: [packages/eas-cli/src/commands/workflow/run.ts](https://github.com/expo/eas-cli/blob/v20.5.1/packages/eas-cli/src/commands/workflow/run.ts)_
## `eas workflow:runs`
@@ -3109,7 +3109,7 @@ DESCRIPTION
list recent workflow runs for this project, with their IDs, statuses, and timestamps
```
_See code: [packages/eas-cli/src/commands/workflow/runs.ts](https://github.com/expo/eas-cli/blob/v20.5.0/packages/eas-cli/src/commands/workflow/runs.ts)_
_See code: [packages/eas-cli/src/commands/workflow/runs.ts](https://github.com/expo/eas-cli/blob/v20.5.1/packages/eas-cli/src/commands/workflow/runs.ts)_
## `eas workflow:status [WORKFLOW_RUN_ID]`
@@ -3137,7 +3137,7 @@ FLAG DESCRIPTIONS
Exit codes: 0 = success, 11 = failure, 12 = canceled, 13 = wait aborted.
```
_See code: [packages/eas-cli/src/commands/workflow/status.ts](https://github.com/expo/eas-cli/blob/v20.5.0/packages/eas-cli/src/commands/workflow/status.ts)_
_See code: [packages/eas-cli/src/commands/workflow/status.ts](https://github.com/expo/eas-cli/blob/v20.5.1/packages/eas-cli/src/commands/workflow/status.ts)_
## `eas workflow:validate PATH`
@@ -3157,7 +3157,7 @@ DESCRIPTION
validate a workflow configuration yaml file
```
_See code: [packages/eas-cli/src/commands/workflow/validate.ts](https://github.com/expo/eas-cli/blob/v20.5.0/packages/eas-cli/src/commands/workflow/validate.ts)_
_See code: [packages/eas-cli/src/commands/workflow/validate.ts](https://github.com/expo/eas-cli/blob/v20.5.1/packages/eas-cli/src/commands/workflow/validate.ts)_
## `eas workflow:view [ID]`
@@ -3179,5 +3179,5 @@ DESCRIPTION
workflow runs for the current project.
```
_See code: [packages/eas-cli/src/commands/workflow/view.ts](https://github.com/expo/eas-cli/blob/v20.5.0/packages/eas-cli/src/commands/workflow/view.ts)_
_See code: [packages/eas-cli/src/commands/workflow/view.ts](https://github.com/expo/eas-cli/blob/v20.5.1/packages/eas-cli/src/commands/workflow/view.ts)_
<!-- commandsstop -->
+377 -16
View File
@@ -419,6 +419,22 @@
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "aiChatEnabled",
"description": null,
"args": [],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "Boolean",
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "appCount",
"description": null,
@@ -4277,9 +4293,58 @@
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "setAiChatEnabled",
"description": "Set whether the AI chat is enabled for this account.",
"args": [
{
"name": "accountID",
"description": null,
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "ID",
"ofType": null
}
},
"defaultValue": null,
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "aiChatEnabled",
"description": null,
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "Boolean",
"ofType": null
}
},
"defaultValue": null,
"isDeprecated": false,
"deprecationReason": null
}
],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "OBJECT",
"name": "Account",
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "setDisplayName",
"description": "Set the display name for the account.",
"description": "Set the display name for the account. Pass null to clear it and fall back to the account identifier.",
"args": [
{
"name": "accountID",
@@ -4301,13 +4366,9 @@
"name": "displayName",
"description": null,
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "String",
"ofType": null
}
"kind": "SCALAR",
"name": "String",
"ofType": null
},
"defaultValue": null,
"isDeprecated": false,
@@ -15890,6 +15951,18 @@
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "body",
"description": null,
"args": [],
"type": {
"kind": "SCALAR",
"name": "String",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "clientVersion",
"description": null,
@@ -22182,13 +22255,9 @@
"description": null,
"args": [],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "OBJECT",
"name": "RemoteAppStoreConnectApp",
"ofType": null
}
"kind": "OBJECT",
"name": "RemoteAppStoreConnectApp",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
@@ -22391,6 +22460,270 @@
"enumValues": null,
"possibleTypes": null
},
{
"kind": "OBJECT",
"name": "AppStoreConnectBuild",
"description": null,
"fields": [
{
"name": "ascBuildIdentifier",
"description": null,
"args": [],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "String",
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "buildNumber",
"description": null,
"args": [],
"type": {
"kind": "SCALAR",
"name": "String",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "expirationDate",
"description": null,
"args": [],
"type": {
"kind": "SCALAR",
"name": "DateTime",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "minOsVersion",
"description": null,
"args": [],
"type": {
"kind": "SCALAR",
"name": "String",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "processingState",
"description": null,
"args": [],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "ENUM",
"name": "AppStoreConnectBuildProcessingState",
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "uploadedDate",
"description": null,
"args": [],
"type": {
"kind": "SCALAR",
"name": "DateTime",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
}
],
"inputFields": null,
"interfaces": [],
"enumValues": null,
"possibleTypes": null
},
{
"kind": "ENUM",
"name": "AppStoreConnectBuildProcessingState",
"description": null,
"fields": null,
"inputFields": null,
"interfaces": null,
"enumValues": [
{
"name": "FAILED",
"description": null,
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "INVALID",
"description": null,
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "PROCESSING",
"description": null,
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "VALID",
"description": null,
"isDeprecated": false,
"deprecationReason": null
}
],
"possibleTypes": null
},
{
"kind": "OBJECT",
"name": "AppStoreConnectBuildUpload",
"description": null,
"fields": [
{
"name": "appStoreConnectBuild",
"description": null,
"args": [],
"type": {
"kind": "OBJECT",
"name": "AppStoreConnectBuild",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "ascBuildUploadIdentifier",
"description": null,
"args": [],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "String",
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "buildNumber",
"description": null,
"args": [],
"type": {
"kind": "SCALAR",
"name": "String",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "createdDate",
"description": null,
"args": [],
"type": {
"kind": "SCALAR",
"name": "DateTime",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "uploadState",
"description": null,
"args": [],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "ENUM",
"name": "AppStoreConnectBuildUploadState",
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "uploadedDate",
"description": null,
"args": [],
"type": {
"kind": "SCALAR",
"name": "DateTime",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "version",
"description": null,
"args": [],
"type": {
"kind": "SCALAR",
"name": "String",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
}
],
"inputFields": null,
"interfaces": [],
"enumValues": null,
"possibleTypes": null
},
{
"kind": "ENUM",
"name": "AppStoreConnectBuildUploadState",
"description": null,
"fields": null,
"inputFields": null,
"interfaces": null,
"enumValues": [
{
"name": "AWAITING_UPLOAD",
"description": null,
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "COMPLETE",
"description": null,
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "FAILED",
"description": null,
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "PROCESSING",
"description": null,
"isDeprecated": false,
"deprecationReason": null
}
],
"possibleTypes": null
},
{
"kind": "ENUM",
"name": "AppStoreConnectUserRole",
@@ -49003,6 +49336,22 @@
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "sourceIpaUrl",
"description": null,
"args": [],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "String",
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
}
],
"inputFields": null,
@@ -68938,6 +69287,18 @@
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "appStoreConnectBuildUpload",
"description": null,
"args": [],
"type": {
"kind": "OBJECT",
"name": "AppStoreConnectBuildUpload",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "archiveUrl",
"description": null,
@@ -86912,7 +87273,7 @@
{
"kind": "OBJECT",
"name": "WorkflowDeviceTestCaseInsightsMetric",
"description": "A count metric returned for the current window AND the equivalent prior window\n(start − duration → start). The frontend uses (current, previous) to compute\ntrend deltas. Trend ratios are NOT pre-computed server-side — see\nWorkflowsInsightsMetric for the equivalent convention.\n\nFloat (not Int) to match WorkflowsInsightsMetric and avoid the GraphQL Int32\nceiling: 90-day uniqExact counts on a high-volume project can plausibly\nexceed 2.1B. Values are integer-valued; the frontend reads them as JS numbers.",
"description": "A count metric returned for the current window AND the equivalent prior window\n(start − duration → start). The frontend uses (current, previous) to compute\ntrend deltas. Trend ratios are NOT pre-computed server-side — see\nWorkflowsInsightsMetric for the equivalent convention.\n\nFloat (not Int) to match WorkflowsInsightsMetric and avoid the GraphQL Int32\nceiling: year-long uniqExact counts on a high-volume project can plausibly\nexceed 2.1B. Values are integer-valued; the frontend reads them as JS numbers.",
"fields": [
{
"name": "currentValue",
+4 -4
View File
@@ -1,6 +1,6 @@
{
"name": "eas-cli",
"version": "20.5.0",
"version": "20.5.1",
"description": "EAS command line tool",
"keywords": [
"cli",
@@ -48,8 +48,8 @@
"@expo/code-signing-certificates": "0.0.5",
"@expo/config": "55.0.10",
"@expo/config-plugins": "55.0.7",
"@expo/eas-build-job": "20.1.0",
"@expo/eas-json": "20.1.0",
"@expo/eas-build-job": "20.5.1",
"@expo/eas-json": "20.5.1",
"@expo/env": "^1.0.0",
"@expo/json-file": "8.3.3",
"@expo/logger": "20.0.0",
@@ -64,7 +64,7 @@
"@expo/results": "1.0.0",
"@expo/rudder-sdk-node": "1.1.1",
"@expo/spawn-async": "1.7.2",
"@expo/steps": "20.1.0",
"@expo/steps": "20.5.1",
"@expo/timeago.js": "1.0.0",
"@oclif/core": "^4.8.3",
"@oclif/plugin-autocomplete": "^3.2.40",
@@ -152,9 +152,27 @@ describe('Go command', () => {
it('prompts for SDK version when no project config is found', async () => {
mockGetConfigFilePaths.mockReturnValue({ staticConfigPath: null, dynamicConfigPath: null });
jest.mocked(WorkflowRunQuery.expoGoSupportedSdkVersionsAsync).mockResolvedValue([
{ sdkVersion: '54.0.0', isLatest: false, isBeta: false, isDeprecated: false },
{ sdkVersion: '55.0.0', isLatest: true, isBeta: false, isDeprecated: false },
{ sdkVersion: '56.0.0', isLatest: false, isBeta: true, isDeprecated: false },
{
sdkVersion: '54.0.0',
isLatest: false,
isBeta: false,
isDeprecated: false,
sourceIpaUrl: 'https://example.com/ipa',
},
{
sdkVersion: '55.0.0',
isLatest: true,
isBeta: false,
isDeprecated: false,
sourceIpaUrl: 'https://example.com/ipa',
},
{
sdkVersion: '56.0.0',
isLatest: false,
isBeta: true,
isDeprecated: false,
sourceIpaUrl: 'https://example.com/ipa',
},
]);
jest.mocked(selectAsync).mockResolvedValue('55.0.0');
@@ -173,11 +191,15 @@ describe('Go command', () => {
it('skips prompt when all versions are deprecated', async () => {
mockGetConfigFilePaths.mockReturnValue({ staticConfigPath: null, dynamicConfigPath: null });
jest
.mocked(WorkflowRunQuery.expoGoSupportedSdkVersionsAsync)
.mockResolvedValue([
{ sdkVersion: '54.0.0', isLatest: false, isBeta: false, isDeprecated: true },
]);
jest.mocked(WorkflowRunQuery.expoGoSupportedSdkVersionsAsync).mockResolvedValue([
{
sdkVersion: '54.0.0',
isLatest: false,
isBeta: false,
isDeprecated: true,
sourceIpaUrl: 'https://example.com/ipa',
},
]);
await makeCmd().run();
+53 -6
View File
@@ -85,6 +85,7 @@ export type Account = {
accountFeatureGates: Scalars['JSONObject']['output'];
/** Coalesced project activity for all apps belonging to this account. */
activityTimelineProjectActivities: Array<ActivityTimelineProjectActivity>;
aiChatEnabled: Scalars['Boolean']['output'];
appCount: Scalars['Int']['output'];
/** @deprecated Use appStoreConnectApiKeysPaginated */
appStoreConnectApiKeys: Array<AppStoreConnectApiKey>;
@@ -691,7 +692,9 @@ export type AccountMutation = {
requestRefund?: Maybe<Scalars['Boolean']['output']>;
/** Revoke specified Permissions for Actor. Actor must already have at least one permission on the account. */
revokeActorPermissions: Account;
/** Set the display name for the account. */
/** Set whether the AI chat is enabled for this account. */
setAiChatEnabled: Account;
/** Set the display name for the account. Pass null to clear it and fall back to the account identifier. */
setDisplayName: Account;
/** Require authorization to send push notifications for experiences owned by this account */
setPushSecurityEnabled: Account;
@@ -769,9 +772,15 @@ export type AccountMutationRevokeActorPermissionsArgs = {
};
export type AccountMutationSetAiChatEnabledArgs = {
accountID: Scalars['ID']['input'];
aiChatEnabled: Scalars['Boolean']['input'];
};
export type AccountMutationSetDisplayNameArgs = {
accountID: Scalars['ID']['input'];
displayName: Scalars['String']['input'];
displayName?: InputMaybe<Scalars['String']['input']>;
};
@@ -2439,6 +2448,7 @@ export type AppObserveCustomEvent = {
appUpdateId?: Maybe<Scalars['String']['output']>;
appUpdateMessage?: Maybe<Scalars['String']['output']>;
appVersion: Scalars['String']['output'];
body?: Maybe<Scalars['String']['output']>;
clientVersion?: Maybe<Scalars['String']['output']>;
countryCode?: Maybe<Scalars['String']['output']>;
deviceLanguageTag?: Maybe<Scalars['String']['output']>;
@@ -3189,7 +3199,7 @@ export type AppStoreConnectApp = {
ascAppIdentifier: Scalars['String']['output'];
createdAt: Scalars['DateTime']['output'];
id: Scalars['ID']['output'];
remoteAppStoreConnectApp: RemoteAppStoreConnectApp;
remoteAppStoreConnectApp?: Maybe<RemoteAppStoreConnectApp>;
updatedAt: Scalars['DateTime']['output'];
webhookEventTypes: Array<Scalars['String']['output']>;
webhookIdentifier: Scalars['ID']['output'];
@@ -3219,6 +3229,41 @@ export type AppStoreConnectAppMutationDeleteAppStoreConnectAppArgs = {
appStoreConnectAppId: Scalars['ID']['input'];
};
export type AppStoreConnectBuild = {
__typename?: 'AppStoreConnectBuild';
ascBuildIdentifier: Scalars['String']['output'];
buildNumber?: Maybe<Scalars['String']['output']>;
expirationDate?: Maybe<Scalars['DateTime']['output']>;
minOsVersion?: Maybe<Scalars['String']['output']>;
processingState: AppStoreConnectBuildProcessingState;
uploadedDate?: Maybe<Scalars['DateTime']['output']>;
};
export enum AppStoreConnectBuildProcessingState {
Failed = 'FAILED',
Invalid = 'INVALID',
Processing = 'PROCESSING',
Valid = 'VALID'
}
export type AppStoreConnectBuildUpload = {
__typename?: 'AppStoreConnectBuildUpload';
appStoreConnectBuild?: Maybe<AppStoreConnectBuild>;
ascBuildUploadIdentifier: Scalars['String']['output'];
buildNumber?: Maybe<Scalars['String']['output']>;
createdDate?: Maybe<Scalars['DateTime']['output']>;
uploadState: AppStoreConnectBuildUploadState;
uploadedDate?: Maybe<Scalars['DateTime']['output']>;
version?: Maybe<Scalars['String']['output']>;
};
export enum AppStoreConnectBuildUploadState {
AwaitingUpload = 'AWAITING_UPLOAD',
Complete = 'COMPLETE',
Failed = 'FAILED',
Processing = 'PROCESSING'
}
export enum AppStoreConnectUserRole {
AccessToReports = 'ACCESS_TO_REPORTS',
AccountHolder = 'ACCOUNT_HOLDER',
@@ -7007,6 +7052,7 @@ export type ExpoGoSdkVersion = {
isDeprecated: Scalars['Boolean']['output'];
isLatest: Scalars['Boolean']['output'];
sdkVersion: Scalars['String']['output'];
sourceIpaUrl: Scalars['String']['output'];
};
export type FcmSnippet = FcmSnippetLegacy | FcmSnippetV1;
@@ -9763,6 +9809,7 @@ export type Submission = ActivityTimelineProjectActivity & {
actor?: Maybe<Actor>;
androidConfig?: Maybe<AndroidSubmissionConfig>;
app: App;
appStoreConnectBuildUpload?: Maybe<AppStoreConnectBuildUpload>;
archiveUrl?: Maybe<Scalars['String']['output']>;
canRetry: Scalars['Boolean']['output'];
cancelingActor?: Maybe<Actor>;
@@ -12157,7 +12204,7 @@ export type WorkflowDeviceTestCaseInsightsFiltersInput = {
* WorkflowsInsightsMetric for the equivalent convention.
*
* Float (not Int) to match WorkflowsInsightsMetric and avoid the GraphQL Int32
* ceiling: 90-day uniqExact counts on a high-volume project can plausibly
* ceiling: year-long uniqExact counts on a high-volume project can plausibly
* exceed 2.1B. Values are integer-valued; the frontend reads them as JS numbers.
*/
export type WorkflowDeviceTestCaseInsightsMetric = {
@@ -13816,7 +13863,7 @@ export type AscAppLinkAppMetadataQueryVariables = Exact<{
}>;
export type AscAppLinkAppMetadataQuery = { __typename?: 'RootQuery', app: { __typename?: 'AppQuery', byId: { __typename?: 'App', id: string, fullName: string, ownerAccount: { __typename?: 'Account', id: string, name: string, ownerUserActor?: { __typename?: 'SSOUser', id: string, username: string } | { __typename?: 'User', id: string, username: string } | null, users: Array<{ __typename?: 'UserPermission', role: Role, actor: { __typename?: 'PartnerActor', id: string } | { __typename?: 'Robot', id: string } | { __typename?: 'SSOUser', id: string } | { __typename?: 'User', id: string } }> }, appStoreConnectApp?: { __typename?: 'AppStoreConnectApp', id: string, ascAppIdentifier: string, remoteAppStoreConnectApp: { __typename?: 'RemoteAppStoreConnectApp', ascAppIdentifier: string, bundleIdentifier: string, name?: string | null, appStoreIconUrl?: string | null } } | null } } };
export type AscAppLinkAppMetadataQuery = { __typename?: 'RootQuery', app: { __typename?: 'AppQuery', byId: { __typename?: 'App', id: string, fullName: string, ownerAccount: { __typename?: 'Account', id: string, name: string, ownerUserActor?: { __typename?: 'SSOUser', id: string, username: string } | { __typename?: 'User', id: string, username: string } | null, users: Array<{ __typename?: 'UserPermission', role: Role, actor: { __typename?: 'PartnerActor', id: string } | { __typename?: 'Robot', id: string } | { __typename?: 'SSOUser', id: string } | { __typename?: 'User', id: string } }> }, appStoreConnectApp?: { __typename?: 'AppStoreConnectApp', id: string, ascAppIdentifier: string, remoteAppStoreConnectApp?: { __typename?: 'RemoteAppStoreConnectApp', ascAppIdentifier: string, bundleIdentifier: string, name?: string | null, appStoreIconUrl?: string | null } | null } | null } } };
export type DiscoverAccessibleAppStoreConnectAppsQueryVariables = Exact<{
appStoreConnectApiKeyId: Scalars['ID']['input'];
@@ -14313,7 +14360,7 @@ export type WorkflowJobByIdQuery = { __typename?: 'RootQuery', workflowJobs: { _
export type ExpoGoSupportedSdkVersionsQueryVariables = Exact<{ [key: string]: never; }>;
export type ExpoGoSupportedSdkVersionsQuery = { __typename?: 'RootQuery', expoGoBuild: { __typename?: 'ExpoGoBuildQuery', supportedSdkVersions: Array<{ __typename?: 'ExpoGoSdkVersion', sdkVersion: string, isLatest: boolean, isBeta: boolean, isDeprecated: boolean }> } };
export type ExpoGoSupportedSdkVersionsQuery = { __typename?: 'RootQuery', expoGoBuild: { __typename?: 'ExpoGoBuildQuery', supportedSdkVersions: Array<{ __typename?: 'ExpoGoSdkVersion', sdkVersion: string, isLatest: boolean, isBeta: boolean, isDeprecated: boolean, sourceIpaUrl: string }> } };
export type ExpoGoRepackConfigurationQueryVariables = Exact<{
input: ExpoGoRepackInput;
@@ -38,6 +38,7 @@ export const WorkflowRunQuery = {
isLatest
isBeta
isDeprecated
sourceIpaUrl
}
}
}
@@ -33,8 +33,8 @@ export function buildJsonOutput(
? {
id: link.id,
ascAppIdentifier: link.ascAppIdentifier,
name: link.remoteAppStoreConnectApp.name ?? null,
bundleIdentifier: link.remoteAppStoreConnectApp.bundleIdentifier ?? null,
name: link.remoteAppStoreConnectApp?.name ?? null,
bundleIdentifier: link.remoteAppStoreConnectApp?.bundleIdentifier ?? null,
appleUrl: getAppleAppUrl(link.ascAppIdentifier),
}
: null,
@@ -69,8 +69,10 @@ export function formatAscAppLinkStatus(metadata: AscAppLinkMetadata): string {
];
const remote = link.remoteAppStoreConnectApp;
lines.push(` Name: ${remote.name}`);
lines.push(` Bundle ID: ${remote.bundleIdentifier}`);
if (remote) {
lines.push(` Name: ${remote.name}`);
lines.push(` Bundle ID: ${remote.bundleIdentifier}`);
}
lines.push(` Apple URL: ${getAppleAppUrl(link.ascAppIdentifier)}`);
return lines.join('\n');
@@ -1,5 +1,6 @@
import fs from 'fs-extra';
import mockdate from 'mockdate';
import { Response } from 'node-fetch';
import path from 'path';
import { instance, mock } from 'ts-mockito';
import { v4 as uuidv4 } from 'uuid';
@@ -9,6 +10,7 @@ import { AssetMetadataStatus } from '../../graphql/generated';
import { PublishMutation } from '../../graphql/mutations/PublishMutation';
import { PublishQuery } from '../../graphql/queries/PublishQuery';
import { RequestedPlatform } from '../../platform';
import { uploadWithPresignedPostWithRetryAsync } from '../../uploads';
import { expoCommandAsync } from '../../utils/expoCli';
import {
MetadataJoi,
@@ -572,9 +574,11 @@ describe(uploadAssetsAsync, () => {
await fs.remove(userDefinedPath);
});
jest.spyOn(PublishMutation, 'getUploadURLsAsync').mockImplementation(async () => {
return { specifications: ['{}', '{}', '{}'] };
});
jest
.spyOn(PublishMutation, 'getUploadURLsAsync')
.mockImplementation(async (_client, contentTypes) => {
return { specifications: contentTypes.map(() => '{}') };
});
jest
.spyOn(PublishQuery, 'getAssetLimitPerUpdateGroupAsync')
@@ -582,27 +586,16 @@ describe(uploadAssetsAsync, () => {
it('resolves if the assets are already uploaded', async () => {
const graphqlClient = instance(mock<ExpoGraphqlClient>());
jest.spyOn(PublishQuery, 'getAssetMetadataAsync').mockImplementation(async () => {
const status = AssetMetadataStatus.Exists;
jest.runAllTimers();
return [
{
storageKey: 'qbgckgkgfdjnNuf9dQd7FDTWUmlEEzg7l1m1sKzQaq0',
status,
__typename: 'AssetMetadataResult',
}, // userDefinedAsset
{
storageKey: 'bbjgXFSIXtjviGwkaPFY0HG4dVVIGiXHAboRFQEqVa4',
status,
__typename: 'AssetMetadataResult',
}, // android.code
{
storageKey: 'dP-nC8EJXKz42XKh_Rc9tYxiGAT-ilpkRltEi6HIKeQ',
status,
__typename: 'AssetMetadataResult',
}, // ios.code
];
});
jest
.spyOn(PublishQuery, 'getAssetMetadataAsync')
.mockImplementation(async (_client, storageKeys) => {
jest.runAllTimers();
return storageKeys.map(storageKey => ({
storageKey,
status: AssetMetadataStatus.Exists,
__typename: 'AssetMetadataResult' as const,
}));
});
mockdate.set(0);
await expect(
@@ -623,31 +616,22 @@ describe(uploadAssetsAsync, () => {
assetLimitPerUpdateGroup: expectedAssetLimit,
});
});
it('resolves if the assets are eventually uploaded', async () => {
const graphqlClient = instance(mock<ExpoGraphqlClient>());
jest.spyOn(PublishQuery, 'getAssetMetadataAsync').mockImplementation(async () => {
const status =
Date.now() === 0 ? AssetMetadataStatus.DoesNotExist : AssetMetadataStatus.Exists;
mockdate.set(Date.now() + 1);
jest.runAllTimers();
return [
{
storageKey: 'qbgckgkgfdjnNuf9dQd7FDTWUmlEEzg7l1m1sKzQaq0',
jest
.spyOn(PublishQuery, 'getAssetMetadataAsync')
.mockImplementation(async (_client, storageKeys) => {
const status =
Date.now() === 0 ? AssetMetadataStatus.DoesNotExist : AssetMetadataStatus.Exists;
mockdate.set(Date.now() + 1);
jest.runAllTimers();
return storageKeys.map(storageKey => ({
storageKey,
status,
__typename: 'AssetMetadataResult',
}, // userDefinedAsset
{
storageKey: 'bbjgXFSIXtjviGwkaPFY0HG4dVVIGiXHAboRFQEqVa4',
status,
__typename: 'AssetMetadataResult',
}, // android.code
{
storageKey: 'dP-nC8EJXKz42XKh_Rc9tYxiGAT-ilpkRltEi6HIKeQ',
status,
__typename: 'AssetMetadataResult',
}, // ios.code
];
});
__typename: 'AssetMetadataResult' as const,
}));
});
mockdate.set(0);
await expect(
@@ -665,38 +649,28 @@ describe(uploadAssetsAsync, () => {
assetCount: 6,
launchAssetCount: 2,
uniqueAssetCount: 3,
uniqueUploadedAssetCount: 2,
uniqueUploadedAssetPaths: [],
uniqueUploadedAssetCount: 3,
uniqueUploadedAssetPaths: [dummyOriginalFilePath],
assetLimitPerUpdateGroup: expectedAssetLimit,
});
});
it('updates spinner text throughout execution', async () => {
it('updates spinner text as each asset finalizes', async () => {
const graphqlClient = instance(mock<ExpoGraphqlClient>());
jest.spyOn(PublishQuery, 'getAssetMetadataAsync').mockImplementation(async () => {
const status =
Date.now() === 0 ? AssetMetadataStatus.DoesNotExist : AssetMetadataStatus.Exists;
mockdate.set(Date.now() + 1);
jest.runAllTimers();
return [
{
storageKey: 'qbgckgkgfdjnNuf9dQd7FDTWUmlEEzg7l1m1sKzQaq0',
jest
.spyOn(PublishQuery, 'getAssetMetadataAsync')
.mockImplementation(async (_client, storageKeys) => {
const status =
Date.now() === 0 ? AssetMetadataStatus.DoesNotExist : AssetMetadataStatus.Exists;
mockdate.set(Date.now() + 1);
jest.runAllTimers();
return storageKeys.map(storageKey => ({
storageKey,
status,
__typename: 'AssetMetadataResult',
}, // userDefinedAsset
{
storageKey: 'bbjgXFSIXtjviGwkaPFY0HG4dVVIGiXHAboRFQEqVa4',
status,
__typename: 'AssetMetadataResult',
}, // android.code
{
storageKey: 'dP-nC8EJXKz42XKh_Rc9tYxiGAT-ilpkRltEi6HIKeQ',
status,
__typename: 'AssetMetadataResult',
}, // ios.code
];
});
const onAssetUploadResultsChangedFn = jest.fn(_assetUploadResults => {});
__typename: 'AssetMetadataResult' as const,
}));
});
const onAssetUploadResultsChangedFn = jest.fn();
mockdate.set(0);
await uploadAssetsAsync(
@@ -707,7 +681,538 @@ describe(uploadAssetsAsync, () => {
onAssetUploadResultsChangedFn,
() => {}
);
// 1 (all false, before existence check) + 1 (after check) + 1 (after poll resolves all)
expect(onAssetUploadResultsChangedFn).toHaveBeenCalledTimes(3);
const firstPayload = onAssetUploadResultsChangedFn.mock.calls[0][0] as { finished: boolean }[];
expect(firstPayload.every(r => r.finished)).toBe(false);
const lastPayload = onAssetUploadResultsChangedFn.mock.calls.at(-1)![0] as {
finished: boolean;
}[];
expect(lastPayload.every(r => r.finished)).toBe(true);
});
it('re-uploads an asset that remains unfinalized after FINALIZE_POLL_MS', async () => {
jest.useFakeTimers({ doNotFake: ['Date'] });
try {
// Use a single-asset group to test the re-upload path in isolation
const singleAssetGroup = {
android: {
launchAsset: {
type: 'bundle',
contentType: 'application/javascript',
path: androidBundlePath,
},
assets: [],
},
};
const graphqlClient = instance(mock<ExpoGraphqlClient>());
let metadataCallCount = 0;
jest
.spyOn(PublishQuery, 'getAssetMetadataAsync')
.mockImplementation(async (_client, storageKeys) => {
const callNum = ++metadataCallCount;
jest.runAllTimers();
// call 1: initial batch check → missing
// call 2: first poll (attempt 0) → still missing, advance past deadline
// call 3: first poll (attempt 1) → exists
if (callNum === 2) {
mockdate.set(56_002);
}
const status =
callNum >= 3 ? AssetMetadataStatus.Exists : AssetMetadataStatus.DoesNotExist;
return storageKeys.map(storageKey => ({
storageKey,
status,
__typename: 'AssetMetadataResult' as const,
}));
});
const getUploadURLsSpy = jest
.spyOn(PublishMutation, 'getUploadURLsAsync')
.mockImplementation(async (_client, contentTypes) => ({
specifications: contentTypes.map(() => '{}'),
}));
getUploadURLsSpy.mockClear();
mockdate.set(0);
await uploadAssetsAsync(
graphqlClient,
singleAssetGroup,
testProjectId,
{ isCanceledOrFinished: false },
() => {},
() => {}
);
// 2 URL requests: initial upload (attempt 0) + re-upload (attempt 1)
expect(getUploadURLsSpy).toHaveBeenCalledTimes(2);
// 3 metadata checks: initial batch + 1 timed-out poll + 1 success
expect(metadataCallCount).toBe(3);
} finally {
jest.useRealTimers();
}
});
it('calls notifyProgress incrementally as assets finalize across poll ticks', async () => {
jest.useFakeTimers({ doNotFake: ['Date'] });
try {
const graphqlClient = instance(mock<ExpoGraphqlClient>());
let callCount = 0;
jest
.spyOn(PublishQuery, 'getAssetMetadataAsync')
.mockImplementation(async (_client, storageKeys) => {
const callNum = ++callCount;
jest.runAllTimers();
// call 1: initial check → all missing
// call 2: poll tick 1 → first asset (index 0) finalizes, others still pending
// call 3: poll tick 2 → remaining assets finalize
return storageKeys.map((storageKey, i) => ({
storageKey,
status:
callNum === 1 || (callNum === 2 && i > 0)
? AssetMetadataStatus.DoesNotExist
: AssetMetadataStatus.Exists,
__typename: 'AssetMetadataResult' as const,
}));
});
const onAssetUploadResultsChangedFn = jest.fn();
mockdate.set(0);
await uploadAssetsAsync(
graphqlClient,
assetsForUpdateInfoGroup,
testProjectId,
{ isCanceledOrFinished: false },
onAssetUploadResultsChangedFn,
() => {}
);
// all-false (before check) + all-false (after check) + partial (tick 1) + all-true (tick 2)
expect(onAssetUploadResultsChangedFn).toHaveBeenCalledTimes(4);
const partialResults = onAssetUploadResultsChangedFn.mock.calls[2][0] as {
finished: boolean;
}[];
expect(partialResults.filter(r => r.finished)).toHaveLength(1);
const finalResults = onAssetUploadResultsChangedFn.mock.calls[3][0] as {
finished: boolean;
}[];
expect(finalResults.every(r => r.finished)).toBe(true);
} finally {
jest.useRealTimers();
}
});
it('throws after all upload attempts are exhausted without finalization', async () => {
jest.useFakeTimers({ doNotFake: ['Date'] });
try {
const singleAssetGroup = {
android: {
launchAsset: {
type: 'bundle',
contentType: 'application/javascript',
path: androidBundlePath,
},
assets: [],
},
};
const graphqlClient = instance(mock<ExpoGraphqlClient>());
let callCount = 0;
jest
.spyOn(PublishQuery, 'getAssetMetadataAsync')
.mockImplementation(async (_client, storageKeys) => {
const callNum = ++callCount;
jest.runAllTimers();
// Push time past the 55s deadline on poll calls so each attempt exits after one tick
if (callNum > 1) {
mockdate.set(Date.now() + 56_002);
}
return storageKeys.map(storageKey => ({
storageKey,
status: AssetMetadataStatus.DoesNotExist,
__typename: 'AssetMetadataResult' as const,
}));
});
const getUploadURLsSpy = jest
.spyOn(PublishMutation, 'getUploadURLsAsync')
.mockImplementation(async (_client, contentTypes) => ({
specifications: contentTypes.map(() => '{}'),
}));
getUploadURLsSpy.mockClear();
const onAssetUploadResultsChangedFn = jest.fn();
mockdate.set(0);
let error: Error | undefined;
try {
await uploadAssetsAsync(
graphqlClient,
singleAssetGroup,
testProjectId,
{ isCanceledOrFinished: false },
onAssetUploadResultsChangedFn,
() => {}
);
} catch (e) {
error = e as Error;
}
// Fails loudly rather than publishing an update with unfinalized assets.
expect(error?.message).toContain('were not processed by the server after 3 attempts');
// The error names the offending asset so the user knows what to retry.
expect(error?.message).toContain(androidBundlePath);
// One URL fetch per attempt across all 3 attempts (MAX_UPLOAD_ATTEMPTS).
expect(getUploadURLsSpy).toHaveBeenCalledTimes(3);
// A final progress update reflects the still-unfinalized asset before throwing.
const lastPayload = onAssetUploadResultsChangedFn.mock.calls.at(-1)![0] as {
finished: boolean;
}[];
expect(lastPayload.every(r => !r.finished)).toBe(true);
} finally {
jest.useRealTimers();
}
});
it('signs upload URLs in chunks of 100 for large asset sets', async () => {
const graphqlClient = instance(mock<ExpoGraphqlClient>());
// 101 unique assets (+1 launch bundle) → 102 unique, chunked into 100 + 2 sign calls.
const manyAssetPaths: string[] = [];
const manyAssets: RawAsset[] = [];
for (let i = 0; i < 101; i++) {
const assetPath = uuidv4();
await fs.writeFile(assetPath, Buffer.from(`large-asset-${i}`));
manyAssetPaths.push(assetPath);
manyAssets.push({ contentType: 'image/jpeg', path: assetPath });
}
const largeAssetGroup = {
android: {
launchAsset: {
type: 'bundle',
contentType: 'application/javascript',
path: androidBundlePath,
},
assets: manyAssets,
},
};
let metadataCall = 0;
jest
.spyOn(PublishQuery, 'getAssetMetadataAsync')
.mockImplementation(async (_client, storageKeys) => {
const status =
++metadataCall === 1 ? AssetMetadataStatus.DoesNotExist : AssetMetadataStatus.Exists;
jest.runAllTimers();
return storageKeys.map(storageKey => ({
storageKey,
status,
__typename: 'AssetMetadataResult' as const,
}));
});
const getUploadURLsSpy = jest
.spyOn(PublishMutation, 'getUploadURLsAsync')
.mockImplementation(async (_client, contentTypes) => ({
specifications: contentTypes.map(() => '{}'),
}));
getUploadURLsSpy.mockClear();
mockdate.set(0);
const result = await uploadAssetsAsync(
graphqlClient,
largeAssetGroup,
testProjectId,
{ isCanceledOrFinished: false },
() => {},
() => {}
);
expect(result.uniqueUploadedAssetCount).toBe(102);
expect(getUploadURLsSpy).toHaveBeenCalledTimes(2);
expect(getUploadURLsSpy.mock.calls[0][1]).toHaveLength(100);
expect(getUploadURLsSpy.mock.calls[1][1]).toHaveLength(2);
await Promise.all(manyAssetPaths.map(assetPath => fs.remove(assetPath)));
});
it('throws if upload URL signing returns fewer URLs than requested', async () => {
const graphqlClient = instance(mock<ExpoGraphqlClient>());
jest
.spyOn(PublishQuery, 'getAssetMetadataAsync')
.mockImplementation(async (_client, storageKeys) => {
jest.runAllTimers();
return storageKeys.map(storageKey => ({
storageKey,
status: AssetMetadataStatus.DoesNotExist,
__typename: 'AssetMetadataResult' as const,
}));
});
// Return one fewer specification than requested content types.
jest
.spyOn(PublishMutation, 'getUploadURLsAsync')
.mockImplementation(async (_client, contentTypes) => ({
specifications: contentTypes.slice(1).map(() => '{}'),
}));
mockdate.set(0);
await expect(
uploadAssetsAsync(
graphqlClient,
assetsForUpdateInfoGroup,
testProjectId,
{ isCanceledOrFinished: false },
() => {},
() => {}
)
).rejects.toThrow('Upload URL signing returned');
});
it('re-uploads only the assets that are still missing', async () => {
jest.useFakeTimers({ doNotFake: ['Date'] });
try {
// Two unique assets: the launch bundle finalizes on the first poll, the image does not,
// so the second attempt must re-upload only the image.
const twoAssetGroup = {
android: {
launchAsset: {
type: 'bundle',
contentType: 'application/javascript',
path: androidBundlePath,
},
assets: [
{
type: 'jpg',
contentType: 'image/jpeg',
path: dummyFilePath,
originalPath: dummyOriginalFilePath,
},
],
},
};
const graphqlClient = instance(mock<ExpoGraphqlClient>());
let callNum = 0;
jest
.spyOn(PublishQuery, 'getAssetMetadataAsync')
.mockImplementation(async (_client, storageKeys) => {
callNum++;
jest.runAllTimers();
// call 1: initial check → both missing
// call 2: attempt 0 poll → launch bundle (index 0) finalizes, image stays missing; time out
// call 3: attempt 1 poll → image finalizes
if (callNum === 2) {
mockdate.set(56_002);
}
return storageKeys.map((storageKey, i) => ({
storageKey,
status:
callNum >= 3 || (callNum === 2 && i === 0)
? AssetMetadataStatus.Exists
: AssetMetadataStatus.DoesNotExist,
__typename: 'AssetMetadataResult' as const,
}));
});
const getUploadURLsSpy = jest
.spyOn(PublishMutation, 'getUploadURLsAsync')
.mockImplementation(async (_client, contentTypes) => ({
specifications: contentTypes.map(() => '{}'),
}));
getUploadURLsSpy.mockClear();
mockdate.set(0);
const result = await uploadAssetsAsync(
graphqlClient,
twoAssetGroup,
testProjectId,
{ isCanceledOrFinished: false },
() => {},
() => {}
);
expect(result.uniqueUploadedAssetCount).toBe(2);
expect(getUploadURLsSpy).toHaveBeenCalledTimes(2);
// Attempt 0 signs both assets; attempt 1 re-signs only the one that stayed missing.
expect(getUploadURLsSpy.mock.calls[0][1]).toHaveLength(2);
expect(getUploadURLsSpy.mock.calls[1][1]).toHaveLength(1);
} finally {
jest.useRealTimers();
}
});
it('calls onAssetUploadBegin on every upload attempt, including re-uploads', async () => {
jest.useFakeTimers({ doNotFake: ['Date'] });
const uploadMock = jest.mocked(uploadWithPresignedPostWithRetryAsync);
uploadMock.mockImplementation(async (_path, _presignedPost, onBegin) => {
onBegin();
return new Response();
});
try {
const singleAssetGroup = {
android: {
launchAsset: {
type: 'bundle',
contentType: 'application/javascript',
path: androidBundlePath,
},
assets: [],
},
};
const graphqlClient = instance(mock<ExpoGraphqlClient>());
let callNum = 0;
jest
.spyOn(PublishQuery, 'getAssetMetadataAsync')
.mockImplementation(async (_client, storageKeys) => {
callNum++;
jest.runAllTimers();
if (callNum === 2) {
mockdate.set(56_002); // time out attempt 0's poll so a re-upload happens
}
return storageKeys.map(storageKey => ({
storageKey,
status: callNum >= 3 ? AssetMetadataStatus.Exists : AssetMetadataStatus.DoesNotExist,
__typename: 'AssetMetadataResult' as const,
}));
});
jest
.spyOn(PublishMutation, 'getUploadURLsAsync')
.mockImplementation(async (_client, contentTypes) => ({
specifications: contentTypes.map(() => '{}'),
}));
const onAssetUploadBegin = jest.fn();
mockdate.set(0);
await uploadAssetsAsync(
graphqlClient,
singleAssetGroup,
testProjectId,
{ isCanceledOrFinished: false },
() => {},
onAssetUploadBegin
);
// The caller resets its inactivity watchdog on this callback, so re-uploads must fire it too.
expect(onAssetUploadBegin).toHaveBeenCalledTimes(2);
} finally {
uploadMock.mockReset();
jest.useRealTimers();
}
});
it('throws "Canceled upload" when canceled during the finalize poll', async () => {
jest.useFakeTimers({ doNotFake: ['Date'] });
try {
const cancelationToken = { isCanceledOrFinished: false };
const graphqlClient = instance(mock<ExpoGraphqlClient>());
let callNum = 0;
jest
.spyOn(PublishQuery, 'getAssetMetadataAsync')
.mockImplementation(async (_client, storageKeys) => {
callNum++;
jest.runAllTimers();
// Cancel during the first poll tick (call 2), after the initial check and upload.
if (callNum === 2) {
cancelationToken.isCanceledOrFinished = true;
}
return storageKeys.map(storageKey => ({
storageKey,
status: AssetMetadataStatus.DoesNotExist,
__typename: 'AssetMetadataResult' as const,
}));
});
jest
.spyOn(PublishMutation, 'getUploadURLsAsync')
.mockImplementation(async (_client, contentTypes) => ({
specifications: contentTypes.map(() => '{}'),
}));
mockdate.set(0);
await expect(
uploadAssetsAsync(
graphqlClient,
assetsForUpdateInfoGroup,
testProjectId,
cancelationToken,
() => {},
() => {}
)
).rejects.toThrow('Canceled upload');
} finally {
jest.useRealTimers();
}
});
it('throws "Canceled upload" when canceled during upload signing', async () => {
const cancelationToken = { isCanceledOrFinished: false };
const graphqlClient = instance(mock<ExpoGraphqlClient>());
jest
.spyOn(PublishQuery, 'getAssetMetadataAsync')
.mockImplementation(async (_client, storageKeys) => {
jest.runAllTimers();
return storageKeys.map(storageKey => ({
storageKey,
status: AssetMetadataStatus.DoesNotExist,
__typename: 'AssetMetadataResult' as const,
}));
});
jest
.spyOn(PublishMutation, 'getUploadURLsAsync')
.mockImplementation(async (_client, contentTypes) => ({
specifications: contentTypes.map(() => '{}'),
}));
// Cancel on the second progress callback (fired right after the initial existence check
// passes, before uploading begins) so the cancellation is caught inside the signing loop.
let progressCallCount = 0;
const onAssetUploadResultsChanged = jest.fn(() => {
if (++progressCallCount === 2) {
cancelationToken.isCanceledOrFinished = true;
}
});
mockdate.set(0);
await expect(
uploadAssetsAsync(
graphqlClient,
assetsForUpdateInfoGroup,
testProjectId,
cancelationToken,
onAssetUploadResultsChanged,
() => {}
)
).rejects.toThrow('Canceled upload');
});
it('resolves with zero counts and signs no URLs for an empty asset group', async () => {
const graphqlClient = instance(mock<ExpoGraphqlClient>());
jest
.spyOn(PublishQuery, 'getAssetMetadataAsync')
.mockImplementation(async (_client, storageKeys) => {
jest.runAllTimers();
return storageKeys.map(storageKey => ({
storageKey,
status: AssetMetadataStatus.Exists,
__typename: 'AssetMetadataResult' as const,
}));
});
const getUploadURLsSpy = jest
.spyOn(PublishMutation, 'getUploadURLsAsync')
.mockImplementation(async (_client, contentTypes) => ({
specifications: contentTypes.map(() => '{}'),
}));
getUploadURLsSpy.mockClear();
mockdate.set(0);
const result = await uploadAssetsAsync(
graphqlClient,
{},
testProjectId,
{ isCanceledOrFinished: false },
() => {},
() => {}
);
expect(result).toMatchObject({
assetCount: 0,
launchAssetCount: 0,
uniqueAssetCount: 0,
uniqueUploadedAssetCount: 0,
uniqueUploadedAssetPaths: [],
});
expect(getUploadURLsSpy).not.toHaveBeenCalled();
});
});
+103 -63
View File
@@ -73,6 +73,8 @@ export type RawAsset = {
originalPath?: string;
};
type AssetWithStorageKey = RawAsset & { storageKey: string };
type CollectedAssets = {
[platform in ExpoConfigPlatform]?: {
launchAsset: RawAsset;
@@ -508,23 +510,30 @@ export async function uploadAssetsAsync(
];
}
const assetsWithStorageKey = await Promise.all(
assets.map(async asset => {
return {
...asset,
storageKey: await getStorageKeyForAssetAsync(asset),
};
})
);
const uniqueAssets = uniqBy<
RawAsset & {
storageKey: string;
}
>(assetsWithStorageKey, asset => asset.storageKey);
const [assetLimitPerUpdateGroup, assetsWithStorageKey] = await Promise.all([
PublishQuery.getAssetLimitPerUpdateGroupAsync(graphqlClient, projectId),
Promise.all(
assets.map(async asset => {
return {
...asset,
storageKey: await getStorageKeyForAssetAsync(asset),
};
})
),
]);
const uniqueAssets = uniqBy<AssetWithStorageKey>(assetsWithStorageKey, asset => asset.storageKey);
onAssetUploadResultsChanged?.(uniqueAssets.map(asset => ({ asset, finished: false })));
let missingAssets = await filterOutAssetsThatAlreadyExistAsync(graphqlClient, uniqueAssets);
let missingAssetStorageKeys = new Set(missingAssets.map(a => a.storageKey));
// "finished" = no longer in the pending (still-missing) set.
const notifyProgress = (pendingAssets: AssetWithStorageKey[]): void => {
const pendingKeys = new Set(pendingAssets.map(a => a.storageKey));
onAssetUploadResultsChanged?.(
uniqueAssets.map(asset => ({ asset, finished: !pendingKeys.has(asset.storageKey) }))
);
};
notifyProgress(uniqueAssets);
const missingAssets = await filterOutAssetsThatAlreadyExistAsync(graphqlClient, uniqueAssets);
const uniqueUploadedAssetCount = missingAssets.length;
const uniqueUploadedAssetPaths = missingAssets.map(asset => asset.originalPath).filter(truthy);
@@ -532,64 +541,95 @@ export async function uploadAssetsAsync(
throw Error('Canceled upload');
}
const missingAssetChunks = chunk(missingAssets, 100);
const specifications: string[] = [];
for (const missingAssets of missingAssetChunks) {
const { specifications: chunkSpecifications } = await PublishMutation.getUploadURLsAsync(
graphqlClient,
missingAssets.map(ma => ma.contentType)
);
specifications.push(...chunkSpecifications);
}
onAssetUploadResultsChanged?.(
uniqueAssets.map(asset => ({ asset, finished: !missingAssetStorageKeys.has(asset.storageKey) }))
);
notifyProgress(missingAssets);
// Just past the finalize cloud function's event max age plus a startup buffer. Beyond this,
// waiting longer can't help, so we re-upload to generate a fresh event with a new retry window.
const FINALIZE_POLL_MS = 55_000;
const MAX_UPLOAD_ATTEMPTS = 3;
const assetUploadPromiseLimit = promiseLimit(15);
const [assetLimitPerUpdateGroup] = await Promise.all([
PublishQuery.getAssetLimitPerUpdateGroupAsync(graphqlClient, projectId),
Promise.all(
missingAssets.map((missingAsset, i) => {
return assetUploadPromiseLimit(async () => {
// Sign URLs in chunks (endpoint caps at 100), then upload all from one pool so it stays saturated.
const uploadBatchAsync = async (
assetsToUpload: AssetWithStorageKey[],
onBegin: () => void
): Promise<void> => {
const signedAssets: { asset: AssetWithStorageKey; specification: string }[] = [];
for (const assetChunk of chunk(assetsToUpload, 100)) {
if (cancelationToken.isCanceledOrFinished) {
throw Error('Canceled upload');
}
const { specifications } = await PublishMutation.getUploadURLsAsync(
graphqlClient,
assetChunk.map(a => a.contentType)
);
if (specifications.length !== assetChunk.length) {
throw new Error(
`Upload URL signing returned ${specifications.length} URLs for ${assetChunk.length} assets. ` +
`This is likely a transient server error; please retry, and contact support if it persists.`
);
}
assetChunk.forEach((asset, i) =>
signedAssets.push({ asset, specification: specifications[i] })
);
}
await Promise.all(
signedAssets.map(({ asset, specification }) =>
assetUploadPromiseLimit(async () => {
if (cancelationToken.isCanceledOrFinished) {
throw Error('Canceled upload');
}
const presignedPost: PresignedPost = JSON.parse(specifications[i]);
await uploadWithPresignedPostWithRetryAsync(
missingAsset.path,
presignedPost,
onAssetUploadBegin
);
});
})
),
]);
let timeout = 1;
while (missingAssets.length > 0) {
if (cancelationToken.isCanceledOrFinished) {
throw Error('Canceled upload');
}
const timeoutPromise = new Promise(resolve =>
setTimeout(resolve, Math.min(timeout * 1000, 5000))
); // linear backoff
missingAssets = await filterOutAssetsThatAlreadyExistAsync(graphqlClient, missingAssets);
missingAssetStorageKeys = new Set(missingAssets.map(a => a.storageKey));
await timeoutPromise; // await after filterOutAssetsThatAlreadyExistAsync for easy mocking with jest.runAllTimers
timeout += 1;
onAssetUploadResultsChanged?.(
uniqueAssets.map(asset => ({
asset,
finished: !missingAssetStorageKeys.has(asset.storageKey),
}))
const presignedPost: PresignedPost = JSON.parse(specification);
await uploadWithPresignedPostWithRetryAsync(asset.path, presignedPost, onBegin);
})
)
);
};
const pollForFinalizationAsync = async (
pendingAssets: AssetWithStorageKey[]
): Promise<AssetWithStorageKey[]> => {
const deadline = Date.now() + FINALIZE_POLL_MS;
let pollDelay = 1;
while (pendingAssets.length > 0 && Date.now() < deadline) {
if (cancelationToken.isCanceledOrFinished) {
throw Error('Canceled upload');
}
const waitPromise = new Promise<void>(resolve =>
setTimeout(resolve, Math.min(pollDelay * 1000, 5000))
);
const remaining = await filterOutAssetsThatAlreadyExistAsync(graphqlClient, pendingAssets);
if (remaining.length < pendingAssets.length) {
notifyProgress(remaining);
}
pendingAssets = remaining;
pollDelay++;
await waitPromise; // awaited after the check so jest.runAllTimers can flush it in tests
}
return pendingAssets;
};
// Re-upload assets that don't finalize in time to trigger a fresh finalize event and retry window.
let pendingAssets = missingAssets;
for (let attempt = 0; attempt < MAX_UPLOAD_ATTEMPTS && pendingAssets.length > 0; attempt++) {
await uploadBatchAsync(pendingAssets, onAssetUploadBegin);
pendingAssets = await pollForFinalizationAsync(pendingAssets);
}
cancelationToken.isCanceledOrFinished = true;
if (pendingAssets.length > 0) {
notifyProgress(pendingAssets);
const unfinalizedAssets = pendingAssets
.map(asset => `\n- ${asset.originalPath ?? asset.path}`)
.join('');
throw new Error(
`Update not published: ${pendingAssets.length} asset(s) uploaded but were not processed by the server after ${MAX_UPLOAD_ATTEMPTS} attempts:${unfinalizedAssets}\n\n` +
`The upload succeeded, but the server did not finish processing these assets in time. ` +
`This is usually temporary. Re-run \`eas update\` to try again, and contact support if it keeps happening.`
);
}
return {
assetCount: assets.length,
launchAssetCount: launchAssets.length,
@@ -242,6 +242,26 @@ describe('maybeWarnAboutUsageOveragesAsync', () => {
expect(mockWarn).not.toHaveBeenCalled();
});
it('does not warn a Free plan below its limit even if the usage API reports an overage', async () => {
// Regression: free users with only a handful of builds were told they'd reached
// their limit because a nonzero overage row was treated as billable usage.
mockGetUsageForOverageWarningAsync.mockResolvedValue(
createMockAccountUsage({
subscriptionName: 'Free',
buildPlanMetrics: [createMockPlanMetric({ value: 6, limit: 30 })],
overageMetrics: [{ __typename: 'EstimatedOverageAndCost', id: 'o1', value: 5 }],
totalCost: 0,
})
);
await maybeWarnAboutUsageOveragesAsync({
graphqlClient: mockGraphqlClient,
accountId: 'account-id',
});
expect(mockWarn).not.toHaveBeenCalled();
});
it('handles errors gracefully', async () => {
mockGetUsageForOverageWarningAsync.mockRejectedValue(new Error('Network error'));
@@ -267,26 +287,74 @@ describe('maybeWarnAboutUsageOveragesAsync', () => {
});
describe('classifyUsageTier', () => {
const paid = { overageCostCents: 0, hasFreePlan: false };
it('returns null when usage is below threshold', () => {
expect(classifyUsageTier({ planValue: 50, limit: 100, overageCount: 0 })).toBeNull();
expect(classifyUsageTier({ planValue: 79, limit: 100, overageCount: 0 })).toBeNull();
expect(classifyUsageTier({ planValue: 50, limit: 100, overageCount: 0, ...paid })).toBeNull();
expect(classifyUsageTier({ planValue: 79, limit: 100, overageCount: 0, ...paid })).toBeNull();
});
it('returns "approaching" at >= 80% but below limit', () => {
expect(classifyUsageTier({ planValue: 80, limit: 100, overageCount: 0 })).toBe('approaching');
expect(classifyUsageTier({ planValue: 99, limit: 100, overageCount: 0 })).toBe('approaching');
expect(classifyUsageTier({ planValue: 80, limit: 100, overageCount: 0, ...paid })).toBe(
'approaching'
);
expect(classifyUsageTier({ planValue: 99, limit: 100, overageCount: 0, ...paid })).toBe(
'approaching'
);
});
it('returns "at" when planValue >= limit and no overage', () => {
expect(classifyUsageTier({ planValue: 100, limit: 100, overageCount: 0 })).toBe('at');
expect(classifyUsageTier({ planValue: 100, limit: 100, overageCount: 0, ...paid })).toBe('at');
});
it('returns "over" when any overage has been counted', () => {
expect(classifyUsageTier({ planValue: 105, limit: 100, overageCount: 5 })).toBe('over');
it('returns "over" for a paid plan with a counted, billable overage', () => {
expect(
classifyUsageTier({
planValue: 105,
limit: 100,
overageCount: 5,
overageCostCents: 750,
hasFreePlan: false,
})
).toBe('over');
});
it('prefers "over" if overage exists, regardless of planValue', () => {
expect(classifyUsageTier({ planValue: 50, limit: 100, overageCount: 1 })).toBe('over');
it('prefers "over" if a billable overage exists, regardless of planValue', () => {
expect(
classifyUsageTier({
planValue: 50,
limit: 100,
overageCount: 1,
overageCostCents: 150,
hasFreePlan: false,
})
).toBe('over');
});
it('never returns "over" for a Free plan, even if an overage is reported', () => {
// Regression: free users below their limit were shown the "reached your limit" warning
// because a nonzero overage row was treated as billable usage.
expect(
classifyUsageTier({
planValue: 6,
limit: 30,
overageCount: 5,
overageCostCents: 0,
hasFreePlan: true,
})
).toBeNull();
});
it('does not return "over" for a paid plan when the overage has no billable cost', () => {
expect(
classifyUsageTier({
planValue: 6,
limit: 30,
overageCount: 5,
overageCostCents: 0,
hasFreePlan: false,
})
).toBeNull();
});
});
@@ -35,16 +35,18 @@ export async function maybeWarnAboutUsageOveragesAsync({
const overageCount = buildMetrics.overageMetrics.reduce((sum, o) => sum + o.value, 0);
const overageCostCents = buildMetrics.totalCost;
const hasFreePlan = subscription.name === 'Free';
const tier = classifyUsageTier({
planValue: planMetric.value,
limit: planMetric.limit,
overageCount,
overageCostCents,
hasFreePlan,
});
if (!tier) {
return;
}
const hasFreePlan = subscription.name === 'Free';
displayOverageWarning({
tier,
name,
@@ -64,12 +66,22 @@ export function classifyUsageTier({
planValue,
limit,
overageCount,
overageCostCents,
hasFreePlan,
}: {
planValue: number;
limit: number;
overageCount: number;
overageCostCents: number;
hasFreePlan: boolean;
}): UsageTier | null {
if (overageCount > 0) {
// Only paid plans can incur pay-as-you-go overages, and only when there is an
// actual billable cost. Free plans are blocked at the limit and are never charged,
// so they can never be in an "over" state — a nonzero overage row (e.g. a usage
// rollup returned by the API) must not be treated as billable usage. Without this
// guard, free users well below their limit were shown a "you've reached your limit,
// upgrade to keep building" warning.
if (!hasFreePlan && overageCount > 0 && overageCostCents > 0) {
return 'over';
}
if (limit > 0 && planValue >= limit) {
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@expo/eas-json",
"version": "20.1.0",
"version": "20.5.1",
"description": "A library for interacting with eas.json",
"homepage": "https://github.com/expo/eas-cli",
"bugs": "https://github.com/expo/eas-cli/issues",
@@ -32,7 +32,7 @@
},
"dependencies": {
"@babel/code-frame": "7.23.5",
"@expo/eas-build-job": "20.1.0",
"@expo/eas-build-job": "20.5.1",
"chalk": "4.1.2",
"env-string": "1.0.1",
"fs-extra": "11.2.0",
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "eas-cli-local-build-plugin",
"version": "20.4.0",
"version": "20.5.1",
"description": "Tool for running EAS compatible builds on a local machine.",
"bugs": "https://github.com/expo/eas-cli/issues",
"license": "BUSL-1.1",
@@ -26,8 +26,8 @@
"clean": "rimraf node_modules dist coverage \"*.tsbuildinfo\""
},
"dependencies": {
"@expo/build-tools": "20.4.0",
"@expo/eas-build-job": "20.1.0",
"@expo/build-tools": "20.5.1",
"@expo/eas-build-job": "20.5.1",
"@expo/logger": "20.0.0",
"@expo/spawn-async": "^1.7.2",
"@expo/turtle-spawn": "20.0.0",
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@expo/steps",
"version": "20.1.0",
"version": "20.5.1",
"bugs": "https://github.com/expo/eas-cli/issues",
"license": "BUSL-1.1",
"author": "Expo <[email protected]>",
@@ -27,7 +27,7 @@
"clean": "rimraf node_modules dist coverage \"*.tsbuildinfo\""
},
"dependencies": {
"@expo/eas-build-job": "20.1.0",
"@expo/eas-build-job": "20.5.1",
"@expo/logger": "20.0.0",
"@expo/spawn-async": "^1.7.2",
"arg": "^5.0.2",
+4 -4
View File
@@ -1,6 +1,6 @@
{
"name": "@expo/worker",
"version": "20.5.0",
"version": "20.5.1",
"private": true,
"scripts": {
"start": "yarn watch",
@@ -19,16 +19,16 @@
},
"dependencies": {
"@babel/core": "7.24.7",
"@expo/build-tools": "20.4.0",
"@expo/build-tools": "20.5.1",
"@expo/config-plugins": "55.0.7",
"@expo/config-types": "55.0.5",
"@expo/downloader": "20.0.0",
"@expo/eas-build-job": "20.1.0",
"@expo/eas-build-job": "20.5.1",
"@expo/logger": "20.0.0",
"@expo/results": "1.0.0",
"@expo/rudder-sdk-node": "1.1.1",
"@expo/spawn-async": "1.7.2",
"@expo/steps": "20.1.0",
"@expo/steps": "20.5.1",
"@expo/template-file": "20.0.0",
"@expo/turtle-spawn": "20.0.0",
"@hapi/boom": "10.0.1",
+32 -2
View File
@@ -22,9 +22,22 @@ fi
echo "Building $OUTPUT_FILE"
tmp_dir=""
record_sim_build_dir=""
cleanup() {
if [[ -n "$tmp_dir" ]]; then
rm -rf "$tmp_dir"
fi
if [[ -n "$record_sim_build_dir" ]]; then
rm -rf "$record_sim_build_dir"
fi
}
trap cleanup EXIT
tmp_dir=$(mktemp -d)
target_root_dir="$tmp_dir"
target_worker_dir="$tmp_dir/packages/worker"
record_sim_build_dir=$(mktemp -d)
mkdir -p "$target_worker_dir"
@@ -84,7 +97,26 @@ yarn workspaces focus @expo/worker --production
rm -rf tsconfig.json tsconfig.build.json
popd >/dev/null 2>&1
if [[ "$PLATFORM" != "ios" ]]; then
rm -f "$target_root_dir/packages/build-tools/bin/record-sim"
fi
if [[ "$PLATFORM" == "ios" ]]; then
record_sim_package_dir="$ROOT_DIR/packages/build-tools/resources/record-sim"
record_sim_bin_dir="$target_root_dir/packages/build-tools/bin"
mkdir -p "$record_sim_bin_dir"
record_sim_bin_path=$(swift build \
-c release \
--package-path "$record_sim_package_dir" \
--build-path "$record_sim_build_dir" \
--show-bin-path)
swift build \
-c release \
--package-path "$record_sim_package_dir" \
--build-path "$record_sim_build_dir"
cp "$record_sim_bin_path/record-sim" "$record_sim_bin_dir/record-sim"
chmod +x "$record_sim_bin_dir/record-sim"
# build plugin
pushd "$ROOT_DIR/packages/expo-cocoapods-proxy" >/dev/null 2>&1
if command -v brew &> /dev/null; then
@@ -109,5 +141,3 @@ ln -s ../../../../packages/worker/dist/main.js "$target_root_dir/src/services/wo
tar zcf $OUTPUT_FILE -C $target_root_dir .
echo "Tarball is ready: $OUTPUT_FILE"
rm -rf $tmp_dir
Generated
+17 -17
View File
@@ -2856,14 +2856,14 @@ __metadata:
languageName: node
linkType: hard
"@expo/build-tools@npm:20.4.0, @expo/build-tools@workspace:packages/build-tools":
"@expo/build-tools@npm:20.5.1, @expo/build-tools@workspace:packages/build-tools":
version: 0.0.0-use.local
resolution: "@expo/build-tools@workspace:packages/build-tools"
dependencies:
"@expo/config": "npm:55.0.10"
"@expo/config-plugins": "npm:55.0.7"
"@expo/downloader": "npm:20.0.0"
"@expo/eas-build-job": "npm:20.1.0"
"@expo/eas-build-job": "npm:20.5.1"
"@expo/env": "npm:^0.4.0"
"@expo/logger": "npm:20.0.0"
"@expo/package-manager": "npm:1.9.10"
@@ -2871,7 +2871,7 @@ __metadata:
"@expo/repack-app": "npm:~0.6.1"
"@expo/results": "npm:^1.0.0"
"@expo/spawn-async": "npm:1.7.2"
"@expo/steps": "npm:20.1.0"
"@expo/steps": "npm:20.5.1"
"@expo/template-file": "npm:20.0.0"
"@expo/turtle-spawn": "npm:20.0.0"
"@expo/xcpretty": "npm:^4.3.1"
@@ -3113,7 +3113,7 @@ __metadata:
languageName: unknown
linkType: soft
"@expo/eas-build-job@npm:20.1.0, @expo/eas-build-job@workspace:packages/eas-build-job":
"@expo/eas-build-job@npm:20.5.1, @expo/eas-build-job@workspace:packages/eas-build-job":
version: 0.0.0-use.local
resolution: "@expo/eas-build-job@workspace:packages/eas-build-job"
dependencies:
@@ -3132,12 +3132,12 @@ __metadata:
languageName: unknown
linkType: soft
"@expo/eas-json@npm:20.1.0, @expo/eas-json@workspace:packages/eas-json":
"@expo/eas-json@npm:20.5.1, @expo/eas-json@workspace:packages/eas-json":
version: 0.0.0-use.local
resolution: "@expo/eas-json@workspace:packages/eas-json"
dependencies:
"@babel/code-frame": "npm:7.23.5"
"@expo/eas-build-job": "npm:20.1.0"
"@expo/eas-build-job": "npm:20.5.1"
"@tsconfig/node20": "npm:20.1.8"
"@types/babel__code-frame": "npm:7.0.3"
"@types/fs-extra": "npm:11.0.4"
@@ -3485,11 +3485,11 @@ __metadata:
languageName: node
linkType: hard
"@expo/steps@npm:20.1.0, @expo/steps@workspace:packages/steps":
"@expo/steps@npm:20.5.1, @expo/steps@workspace:packages/steps":
version: 0.0.0-use.local
resolution: "@expo/steps@workspace:packages/steps"
dependencies:
"@expo/eas-build-job": "npm:20.1.0"
"@expo/eas-build-job": "npm:20.5.1"
"@expo/logger": "npm:20.0.0"
"@expo/spawn-async": "npm:^1.7.2"
"@jest/globals": "npm:^29.7.0"
@@ -3558,16 +3558,16 @@ __metadata:
resolution: "@expo/worker@workspace:packages/worker"
dependencies:
"@babel/core": "npm:7.24.7"
"@expo/build-tools": "npm:20.4.0"
"@expo/build-tools": "npm:20.5.1"
"@expo/config-plugins": "npm:55.0.7"
"@expo/config-types": "npm:55.0.5"
"@expo/downloader": "npm:20.0.0"
"@expo/eas-build-job": "npm:20.1.0"
"@expo/eas-build-job": "npm:20.5.1"
"@expo/logger": "npm:20.0.0"
"@expo/results": "npm:1.0.0"
"@expo/rudder-sdk-node": "npm:1.1.1"
"@expo/spawn-async": "npm:1.7.2"
"@expo/steps": "npm:20.1.0"
"@expo/steps": "npm:20.5.1"
"@expo/template-file": "npm:20.0.0"
"@expo/turtle-spawn": "npm:20.0.0"
"@hapi/boom": "npm:10.0.1"
@@ -10487,7 +10487,7 @@ __metadata:
resolution: "create-eas-build-function@workspace:packages/create-eas-build-function"
dependencies:
"@expo/package-manager": "npm:1.9.10"
"@expo/steps": "npm:20.1.0"
"@expo/steps": "npm:20.5.1"
"@jest/globals": "npm:^29.7.0"
"@types/jest": "npm:^29.5.12"
"@types/node": "npm:20.14.2"
@@ -11100,8 +11100,8 @@ __metadata:
version: 0.0.0-use.local
resolution: "eas-cli-local-build-plugin@workspace:packages/local-build-plugin"
dependencies:
"@expo/build-tools": "npm:20.4.0"
"@expo/eas-build-job": "npm:20.1.0"
"@expo/build-tools": "npm:20.5.1"
"@expo/eas-build-job": "npm:20.5.1"
"@expo/logger": "npm:20.0.0"
"@expo/spawn-async": "npm:^1.7.2"
"@expo/turtle-spawn": "npm:20.0.0"
@@ -11158,8 +11158,8 @@ __metadata:
"@expo/code-signing-certificates": "npm:0.0.5"
"@expo/config": "npm:55.0.10"
"@expo/config-plugins": "npm:55.0.7"
"@expo/eas-build-job": "npm:20.1.0"
"@expo/eas-json": "npm:20.1.0"
"@expo/eas-build-job": "npm:20.5.1"
"@expo/eas-json": "npm:20.5.1"
"@expo/env": "npm:^1.0.0"
"@expo/json-file": "npm:8.3.3"
"@expo/logger": "npm:20.0.0"
@@ -11174,7 +11174,7 @@ __metadata:
"@expo/results": "npm:1.0.0"
"@expo/rudder-sdk-node": "npm:1.1.1"
"@expo/spawn-async": "npm:1.7.2"
"@expo/steps": "npm:20.1.0"
"@expo/steps": "npm:20.5.1"
"@expo/timeago.js": "npm:1.0.0"
"@graphql-codegen/cli": "npm:5.0.0"
"@graphql-codegen/introspection": "npm:4.0.0"