mirror of
https://github.com/expo/eas-cli.git
synced 2026-11-14 10:08:06 -10:00
Compare commits
19
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ce10c42329 | ||
|
|
081e1a9cf7 | ||
|
|
bc6369204d | ||
|
|
4855745d67 | ||
|
|
a848411278 | ||
|
|
58968ddcd9 | ||
|
|
72ca5c2527 | ||
|
|
8aa4cae097 | ||
|
|
8a66d3808e | ||
|
|
6697add2c0 | ||
|
|
cd47185f29 | ||
|
|
88c51c3ccc | ||
|
|
9247f3cf78 | ||
|
|
bde331da63 | ||
|
|
fd055f08b3 | ||
|
|
785d690e65 | ||
|
|
47335f02f0 | ||
|
|
75d9be813f | ||
|
|
cdaa011ce3 |
No files matched your search
+19
-1
@@ -8,12 +8,30 @@ This is the log of notable changes to EAS CLI and related packages.
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
- [eas-cli] `eas integrations:posthog:connect` now supports existing PostHog user connection. ([#3895](https://github.com/expo/eas-cli/pull/3895) by [@gwdp](https://github.com/gwdp))
|
||||
### 🐛 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
|
||||
|
||||
- [eas-cli] Bump `@expo/apple-utils` to `2.1.22`. ([#3913](https://github.com/expo/eas-cli/pull/3913) by [@sjchmiela](https://github.com/sjchmiela))
|
||||
|
||||
## [20.5.0](https://github.com/expo/eas-cli/releases/tag/v20.5.0) - 2026-06-29
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
- [eas-cli] `eas integrations:posthog:connect` now supports existing PostHog user connection. ([#3895](https://github.com/expo/eas-cli/pull/3895) by [@gwdp](https://github.com/gwdp))
|
||||
|
||||
## [20.4.0](https://github.com/expo/eas-cli/releases/tag/v20.4.0) - 2026-06-25
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"npmClient": "yarn",
|
||||
"version": "20.5.0",
|
||||
"version": "20.5.1",
|
||||
"$schema": "node_modules/lerna/schemas/lerna-schema.json"
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
node_modules/
|
||||
dist/
|
||||
bin/record-sim
|
||||
|
||||
.secrets*
|
||||
!.secrets.example
|
||||
|
||||
@@ -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`.
|
||||
+192
@@ -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()
|
||||
}
|
||||
}
|
||||
+96
@@ -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]
|
||||
}
|
||||
+136
@@ -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 });
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -40,7 +40,7 @@ describe(installDependenciesWithNpmCacheFallbackAsync, () => {
|
||||
env: {
|
||||
EAS_VERBOSE: '1',
|
||||
EAS_USE_NPM_CACHE: '1',
|
||||
NPM_CONFIG_REGISTRY: npmCacheUrl,
|
||||
EAS_BUILD_NPM_CACHE_URL: npmCacheUrl,
|
||||
},
|
||||
logger,
|
||||
cwd: '/tmp/build',
|
||||
@@ -56,6 +56,7 @@ describe(installDependenciesWithNpmCacheFallbackAsync, () => {
|
||||
env: {
|
||||
EAS_VERBOSE: '1',
|
||||
EAS_USE_NPM_CACHE: '1',
|
||||
EAS_BUILD_NPM_CACHE_URL: npmCacheUrl,
|
||||
NPM_CONFIG_REGISTRY: npmCacheUrl,
|
||||
},
|
||||
});
|
||||
@@ -66,6 +67,7 @@ describe(installDependenciesWithNpmCacheFallbackAsync, () => {
|
||||
env: {
|
||||
EAS_VERBOSE: '1',
|
||||
EAS_USE_NPM_CACHE: '1',
|
||||
EAS_BUILD_NPM_CACHE_URL: npmCacheUrl,
|
||||
},
|
||||
});
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
@@ -110,7 +112,7 @@ describe(installDependenciesWithNpmCacheFallbackAsync, () => {
|
||||
packageManager: PackageManager.NPM,
|
||||
env: {
|
||||
EAS_USE_NPM_CACHE: '1',
|
||||
NPM_CONFIG_REGISTRY: npmCacheUrl,
|
||||
EAS_BUILD_NPM_CACHE_URL: npmCacheUrl,
|
||||
},
|
||||
logger,
|
||||
cwd: '/tmp/build',
|
||||
@@ -158,7 +160,7 @@ describe(installDependenciesWithNpmCacheFallbackAsync, () => {
|
||||
packageManager: PackageManager.NPM,
|
||||
env: {
|
||||
EAS_USE_NPM_CACHE: '1',
|
||||
NPM_CONFIG_REGISTRY: 'http://npm.staging.caches.eas-build.internal',
|
||||
EAS_BUILD_NPM_CACHE_URL: 'http://npm.staging.caches.eas-build.internal',
|
||||
},
|
||||
logger,
|
||||
cwd: '/tmp/build',
|
||||
@@ -170,7 +172,7 @@ describe(installDependenciesWithNpmCacheFallbackAsync, () => {
|
||||
expect(Sentry.capture).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not retry when EAS_USE_NPM_CACHE is not enabled', async () => {
|
||||
it('does not retry when the npm cache is not enabled', async () => {
|
||||
const logger = createMockLogger();
|
||||
const npmCacheUrl = 'http://npm.staging.caches.eas-build.internal';
|
||||
const error = Object.assign(
|
||||
@@ -188,9 +190,7 @@ describe(installDependenciesWithNpmCacheFallbackAsync, () => {
|
||||
await expect(
|
||||
installDependenciesWithNpmCacheFallbackAsync({
|
||||
packageManager: PackageManager.NPM,
|
||||
env: {
|
||||
NPM_CONFIG_REGISTRY: npmCacheUrl,
|
||||
},
|
||||
env: {},
|
||||
logger,
|
||||
cwd: '/tmp/build',
|
||||
useFrozenLockfile: false,
|
||||
@@ -200,6 +200,40 @@ describe(installDependenciesWithNpmCacheFallbackAsync, () => {
|
||||
expect(spawn).toHaveBeenCalledTimes(1);
|
||||
expect(Sentry.capture).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('installs through the npm cache registry without retrying when the cache install succeeds', async () => {
|
||||
const logger = createMockLogger();
|
||||
const npmCacheUrl = 'http://npm.staging.caches.eas-build.internal';
|
||||
|
||||
jest
|
||||
.mocked(spawn)
|
||||
.mockReturnValueOnce(createSpawnPromise(Promise.resolve(createSpawnResult())));
|
||||
|
||||
await installDependenciesWithNpmCacheFallbackAsync({
|
||||
packageManager: PackageManager.NPM,
|
||||
env: {
|
||||
EAS_USE_NPM_CACHE: '1',
|
||||
EAS_BUILD_NPM_CACHE_URL: npmCacheUrl,
|
||||
},
|
||||
logger,
|
||||
cwd: '/tmp/build',
|
||||
useFrozenLockfile: false,
|
||||
});
|
||||
|
||||
expect(spawn).toHaveBeenCalledTimes(1);
|
||||
expect(spawn).toHaveBeenCalledWith('npm', ['install', '--include=dev'], {
|
||||
cwd: '/tmp/build',
|
||||
logger,
|
||||
infoCallbackFn: undefined,
|
||||
lineTransformer: expect.any(Function),
|
||||
env: {
|
||||
EAS_USE_NPM_CACHE: '1',
|
||||
EAS_BUILD_NPM_CACHE_URL: npmCacheUrl,
|
||||
NPM_CONFIG_REGISTRY: npmCacheUrl,
|
||||
},
|
||||
});
|
||||
expect(Sentry.capture).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
function createSpawnPromise(result: Promise<SpawnResult>): SpawnPromise<SpawnResult> {
|
||||
|
||||
@@ -93,7 +93,24 @@ export async function installDependenciesWithNpmCacheFallbackAsync({
|
||||
infoCallbackFn?: SpawnOptions['infoCallbackFn'];
|
||||
useFrozenLockfile: boolean;
|
||||
}): Promise<void> {
|
||||
const npmCacheUrl = env.NPM_CONFIG_REGISTRY;
|
||||
const npmCacheUrl = env.EAS_BUILD_NPM_CACHE_URL;
|
||||
|
||||
if (!npmCacheUrl) {
|
||||
await (
|
||||
await installDependenciesAsync({
|
||||
packageManager,
|
||||
env,
|
||||
logger,
|
||||
infoCallbackFn,
|
||||
cwd,
|
||||
useFrozenLockfile,
|
||||
})
|
||||
).spawnPromise;
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info(`Installing dependencies using the npm cache registry (${npmCacheUrl}).`);
|
||||
|
||||
let firstErrorLine: string | undefined;
|
||||
let errorLineCount = 0;
|
||||
|
||||
@@ -101,11 +118,11 @@ export async function installDependenciesWithNpmCacheFallbackAsync({
|
||||
await (
|
||||
await installDependenciesAsync({
|
||||
packageManager,
|
||||
env,
|
||||
env: { ...env, NPM_CONFIG_REGISTRY: npmCacheUrl },
|
||||
logger,
|
||||
infoCallbackFn,
|
||||
lineTransformer: (line: string) => {
|
||||
if (isNpmCacheRegistryErrorLine(line, { env, npmCacheUrl })) {
|
||||
if (isNpmCacheRegistryErrorLine(line, npmCacheUrl)) {
|
||||
firstErrorLine ??= line;
|
||||
errorLineCount += 1;
|
||||
}
|
||||
@@ -124,7 +141,7 @@ export async function installDependenciesWithNpmCacheFallbackAsync({
|
||||
});
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
if (!isNpmCacheInstallFailure(err, { env, npmCacheUrl })) {
|
||||
if (!isNpmCacheInstallFailure(err, npmCacheUrl)) {
|
||||
throw err;
|
||||
}
|
||||
|
||||
@@ -144,11 +161,10 @@ export async function installDependenciesWithNpmCacheFallbackAsync({
|
||||
},
|
||||
});
|
||||
|
||||
const { NPM_CONFIG_REGISTRY: _NPM_CONFIG_REGISTRY, ...fallbackEnv } = env;
|
||||
await (
|
||||
await installDependenciesAsync({
|
||||
packageManager,
|
||||
env: fallbackEnv,
|
||||
env,
|
||||
logger,
|
||||
infoCallbackFn,
|
||||
cwd,
|
||||
@@ -172,35 +188,17 @@ export function resolvePackagerDir(ctx: BuildContext<Job>): string {
|
||||
return packagerRunDir;
|
||||
}
|
||||
|
||||
function isNpmCacheInstallFailure(
|
||||
err: unknown,
|
||||
{ env, npmCacheUrl }: { env: Record<string, string | undefined>; npmCacheUrl: string | undefined }
|
||||
): boolean {
|
||||
if (!isNpmCacheRegistryEnabled(env, npmCacheUrl)) {
|
||||
return false;
|
||||
}
|
||||
const errorOutput = getErrorOutput(err);
|
||||
return errorOutput.includes(npmCacheUrl);
|
||||
function isNpmCacheInstallFailure(err: unknown, npmCacheUrl: string): boolean {
|
||||
return getErrorOutput(err).includes(npmCacheUrl);
|
||||
}
|
||||
|
||||
function isNpmCacheRegistryErrorLine(
|
||||
line: string,
|
||||
{ env, npmCacheUrl }: { env: Record<string, string | undefined>; npmCacheUrl: string | undefined }
|
||||
): boolean {
|
||||
function isNpmCacheRegistryErrorLine(line: string, npmCacheUrl: string): boolean {
|
||||
return (
|
||||
isNpmCacheRegistryEnabled(env, npmCacheUrl) &&
|
||||
line.includes(npmCacheUrl) &&
|
||||
/(?:error|failed|ENOTFOUND|ECONN|ETIMEDOUT|EAI_AGAIN|FetchError)/i.test(line)
|
||||
);
|
||||
}
|
||||
|
||||
function isNpmCacheRegistryEnabled(
|
||||
env: Record<string, string | undefined>,
|
||||
npmCacheUrl: string | undefined
|
||||
): npmCacheUrl is string {
|
||||
return env.EAS_USE_NPM_CACHE === '1' && !!npmCacheUrl;
|
||||
}
|
||||
|
||||
function getErrorOutput(err: unknown): string {
|
||||
if (!(err instanceof Error)) {
|
||||
return '';
|
||||
|
||||
@@ -233,7 +233,7 @@ async function uploadProjectMetadataAsync(
|
||||
}
|
||||
`),
|
||||
{
|
||||
buildId: ctx.env.EAS_BUILD_ID,
|
||||
buildId: nullthrows(ctx.env.EAS_BUILD_ID, 'EAS_BUILD_ID is not set'),
|
||||
projectMetadataFile: {
|
||||
type: 'GCS',
|
||||
bucketKey: uploadSession.bucketKey,
|
||||
|
||||
@@ -63,7 +63,7 @@ export interface BuildContextOptions {
|
||||
reportError?: (
|
||||
msg: string,
|
||||
err?: Error,
|
||||
options?: { tags?: Record<string, string>; extras?: Record<string, string> }
|
||||
options?: { tags?: Record<string, string>; extras?: Record<string, string | undefined> }
|
||||
) => void;
|
||||
skipNativeBuild?: boolean;
|
||||
metadata?: Metadata;
|
||||
@@ -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;
|
||||
@@ -80,7 +115,7 @@ export class BuildContext<TJob extends Job = Job> {
|
||||
public readonly reportError?: (
|
||||
msg: string,
|
||||
err?: Error,
|
||||
options?: { tags?: Record<string, string>; extras?: Record<string, string> }
|
||||
options?: { tags?: Record<string, string>; extras?: Record<string, string | undefined> }
|
||||
) => void;
|
||||
public readonly skipNativeBuild?: boolean;
|
||||
public readonly expoApiV2BaseUrl?: string;
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
@@ -93,7 +100,7 @@ export async function runFastlane(
|
||||
cwd,
|
||||
}: {
|
||||
logger?: bunyan;
|
||||
env?: Record<string, string>;
|
||||
env?: Env;
|
||||
cwd?: string;
|
||||
} = {}
|
||||
): Promise<SpawnResult> {
|
||||
|
||||
@@ -27,7 +27,7 @@ describe(createRepackBuildFunction, () => {
|
||||
vol.reset();
|
||||
});
|
||||
|
||||
it('should set the output path for successful repack', async () => {
|
||||
it('should set generated output path for successful repack', async () => {
|
||||
const repack = createRepackBuildFunction();
|
||||
const repackStep = repack.createBuildStepFromFunctionCall(
|
||||
createGlobalContextMock({
|
||||
@@ -38,14 +38,33 @@ describe(createRepackBuildFunction, () => {
|
||||
{
|
||||
callInputs: {
|
||||
platform: 'ios',
|
||||
source_app_path: '/path/to/source_app',
|
||||
output_path: '/path/to/output_app',
|
||||
source_app_path: '/path/to/source_app.ipa',
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
await repackStep.executeAsync();
|
||||
expect(repackStep.outputById['output_path'].value).toBe('/path/to/output_app');
|
||||
expect(repackStep.outputById['output_path'].value).toMatch(/repacked-.*\.ipa$/);
|
||||
});
|
||||
|
||||
it('should rename generated aab output path to apk', async () => {
|
||||
const repack = createRepackBuildFunction();
|
||||
const repackStep = repack.createBuildStepFromFunctionCall(
|
||||
createGlobalContextMock({
|
||||
staticContextContent: {
|
||||
job: createTestAndroidJob(),
|
||||
},
|
||||
}),
|
||||
{
|
||||
callInputs: {
|
||||
platform: 'android',
|
||||
source_app_path: '/path/to/source_app.aab',
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
await repackStep.executeAsync();
|
||||
expect(repackStep.outputById['output_path'].value).toMatch(/repacked-.*\.apk$/);
|
||||
});
|
||||
|
||||
it('should throw for unsupported platforms', async () => {
|
||||
|
||||
@@ -15,6 +15,7 @@ jest.mock('../../../utils/IosSimulatorUtils', () => ({
|
||||
getAvailableDevicesAsync: jest.fn(),
|
||||
getDeviceAsync: jest.fn(),
|
||||
cloneAsync: jest.fn(),
|
||||
enableAccessibilitySettingsAsync: jest.fn(),
|
||||
startAsync: jest.fn(),
|
||||
waitForReadyAsync: jest.fn(),
|
||||
disableApsdAsync: jest.fn(),
|
||||
@@ -34,15 +35,59 @@ function createStep(callInputs?: Record<string, unknown>) {
|
||||
|
||||
describe(createStartIosSimulatorBuildFunction, () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockedSpawn.mockResolvedValue({ stdout: '', stderr: '' } as any);
|
||||
mockedUtils.getAvailableDevicesAsync.mockResolvedValue([]);
|
||||
mockedUtils.getDeviceAsync.mockResolvedValue(null);
|
||||
mockedUtils.cloneAsync.mockResolvedValue(undefined);
|
||||
mockedUtils.enableAccessibilitySettingsAsync.mockResolvedValue(undefined);
|
||||
mockedUtils.startAsync.mockResolvedValue({ udid: 'test-udid' as any });
|
||||
mockedUtils.waitForReadyAsync.mockResolvedValue(undefined);
|
||||
mockedUtils.disableApsdAsync.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
it('does not enable accessibility settings by default', async () => {
|
||||
await createStep({ device_identifier: 'iPhone 15' }).executeAsync();
|
||||
|
||||
expect(mockedUtils.enableAccessibilitySettingsAsync).not.toHaveBeenCalled();
|
||||
expect(mockedUtils.startAsync).toHaveBeenCalledWith({
|
||||
deviceIdentifier: 'iPhone 15',
|
||||
env: expect.any(Object),
|
||||
});
|
||||
});
|
||||
|
||||
it('enables accessibility settings before starting the main device and every clone when requested', async () => {
|
||||
mockedUtils.startAsync
|
||||
.mockResolvedValueOnce({ udid: 'base' as any })
|
||||
.mockResolvedValueOnce({ udid: 'clone-1' as any })
|
||||
.mockResolvedValueOnce({ udid: 'clone-2' as any });
|
||||
|
||||
await createStep({
|
||||
device_identifier: 'iPhone 15',
|
||||
count: 2,
|
||||
enable_accessibility_settings: true,
|
||||
}).executeAsync();
|
||||
|
||||
expect(mockedUtils.enableAccessibilitySettingsAsync).toHaveBeenCalledWith({
|
||||
deviceIdentifier: 'iPhone 15',
|
||||
env: expect.any(Object),
|
||||
});
|
||||
expect(mockedUtils.enableAccessibilitySettingsAsync).toHaveBeenCalledWith({
|
||||
deviceIdentifier: 'eas-simulator-1',
|
||||
env: expect.any(Object),
|
||||
});
|
||||
expect(mockedUtils.enableAccessibilitySettingsAsync).toHaveBeenCalledWith({
|
||||
deviceIdentifier: 'eas-simulator-2',
|
||||
env: expect.any(Object),
|
||||
});
|
||||
for (const [callIndex] of mockedUtils.startAsync.mock.calls.entries()) {
|
||||
const enableCallOrder =
|
||||
mockedUtils.enableAccessibilitySettingsAsync.mock.invocationCallOrder[callIndex];
|
||||
const startCallOrder = mockedUtils.startAsync.mock.invocationCallOrder[callIndex];
|
||||
expect(enableCallOrder).toBeLessThan(startCallOrder);
|
||||
}
|
||||
});
|
||||
|
||||
it('disables apsd on the main device and every clone', async () => {
|
||||
mockedUtils.startAsync
|
||||
.mockResolvedValueOnce({ udid: 'base' as any })
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { asyncResult } from '@expo/results';
|
||||
import { BuildFunction, BuildStepInput, BuildStepInputValueTypeName } from '@expo/steps';
|
||||
|
||||
import { Sentry } from '../../sentry';
|
||||
import { retryOnDNSFailure } from '../../utils/retryOnDNSFailure';
|
||||
|
||||
export function createSubmissionEntityFunction(): BuildFunction {
|
||||
@@ -54,18 +55,42 @@ export function createSubmissionEntityFunction(): BuildFunction {
|
||||
const robotAccessToken = stepsCtx.global.staticContext.job.secrets?.robotAccessToken;
|
||||
if (!robotAccessToken) {
|
||||
stepsCtx.logger.error('Failed to create submission entity: no robot access token found');
|
||||
Sentry.capture('Failed to create submission entity: missing robot access token');
|
||||
return;
|
||||
}
|
||||
|
||||
const buildId = inputs.build_id.value;
|
||||
if (!buildId) {
|
||||
stepsCtx.logger.error('Failed to create submission entity: no build ID provided');
|
||||
Sentry.capture('Failed to create submission entity: missing build ID', {
|
||||
extras: {
|
||||
buildId,
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const workflowJobId = stepsCtx.global.env.__WORKFLOW_JOB_ID;
|
||||
if (!workflowJobId) {
|
||||
stepsCtx.logger.error('Failed to create submission entity: no workflow job ID found');
|
||||
Sentry.capture('Failed to create submission entity: missing workflow job ID', {
|
||||
extras: {
|
||||
buildId,
|
||||
workflowJobId,
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const expoApiServerURL = stepsCtx.global.staticContext.expoApiServerURL;
|
||||
if (!expoApiServerURL) {
|
||||
stepsCtx.logger.error('Failed to create submission entity: no Expo API server URL found');
|
||||
Sentry.capture('Failed to create submission entity: missing Expo API server URL', {
|
||||
extras: {
|
||||
buildId,
|
||||
workflowJobId,
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -83,7 +108,7 @@ export function createSubmissionEntityFunction(): BuildFunction {
|
||||
|
||||
try {
|
||||
const response = await retryOnDNSFailure(fetch)(
|
||||
new URL('/v2/app-store-submissions/', stepsCtx.global.staticContext.expoApiServerURL),
|
||||
new URL('/v2/app-store-submissions/', expoApiServerURL),
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { UserError } from '@expo/eas-build-job';
|
||||
import { SystemError } from '@expo/eas-build-job';
|
||||
import { bunyan } from '@expo/logger';
|
||||
import { asyncResult } from '@expo/results';
|
||||
import {
|
||||
@@ -56,18 +56,23 @@ export function createDownloadArtifactFunction(): BuildFunction {
|
||||
const interpolationContext = stepsCtx.global.getInterpolationContext();
|
||||
|
||||
if (!('workflow' in interpolationContext)) {
|
||||
throw new UserError(
|
||||
'EAS_DOWNLOAD_ARTIFACT_NO_WORKFLOW',
|
||||
'No workflow found in the interpolation context.'
|
||||
);
|
||||
throw new SystemError('No workflow found in the interpolation context.', {
|
||||
trackingCode: 'EAS_DOWNLOAD_ARTIFACT_NO_WORKFLOW',
|
||||
});
|
||||
}
|
||||
|
||||
const robotAccessToken = stepsCtx.global.staticContext.job.secrets?.robotAccessToken;
|
||||
if (!robotAccessToken) {
|
||||
throw new UserError(
|
||||
'EAS_DOWNLOAD_ARTIFACT_NO_ROBOT_ACCESS_TOKEN',
|
||||
'No robot access token found in the job secrets.'
|
||||
);
|
||||
throw new SystemError('No robot access token found in the job secrets.', {
|
||||
trackingCode: 'EAS_DOWNLOAD_ARTIFACT_NO_ROBOT_ACCESS_TOKEN',
|
||||
});
|
||||
}
|
||||
|
||||
const expoApiServerURL = stepsCtx.global.staticContext.expoApiServerURL;
|
||||
if (!expoApiServerURL) {
|
||||
throw new SystemError('Missing Expo API server URL.', {
|
||||
trackingCode: 'EAS_DOWNLOAD_ARTIFACT_NO_EXPO_API_SERVER_URL',
|
||||
});
|
||||
}
|
||||
|
||||
const workflowRunId = interpolationContext.workflow.id;
|
||||
@@ -82,7 +87,7 @@ export function createDownloadArtifactFunction(): BuildFunction {
|
||||
const { artifactPath } = await downloadArtifactAsync({
|
||||
logger,
|
||||
workflowRunId,
|
||||
expoApiServerURL: stepsCtx.global.staticContext.expoApiServerURL,
|
||||
expoApiServerURL,
|
||||
robotAccessToken,
|
||||
params,
|
||||
});
|
||||
|
||||
@@ -42,11 +42,6 @@ export function createRepackBuildFunction(): BuildFunction {
|
||||
required: false,
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
|
||||
}),
|
||||
BuildStepInput.createProvider({
|
||||
id: 'output_path',
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
|
||||
required: false,
|
||||
}),
|
||||
BuildStepInput.createProvider({
|
||||
id: 'embed_bundle_assets',
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.BOOLEAN,
|
||||
@@ -109,9 +104,10 @@ export function createRepackBuildFunction(): BuildFunction {
|
||||
stepsCtx.logger.info(`Created temporary working directory: ${workingDirectory}`);
|
||||
|
||||
const sourceAppPath = inputs.source_app_path.value as string;
|
||||
const outputPath =
|
||||
(inputs.output_path.value as string) ??
|
||||
path.join(tmpDir, `repacked-${randomUUID()}${path.extname(sourceAppPath)}`);
|
||||
const outputPath = createOutputPath({
|
||||
sourceAppPath,
|
||||
tmpDir,
|
||||
});
|
||||
const exportEmbedOptions = inputs.embed_bundle_assets.value
|
||||
? {
|
||||
sourcemapOutput: undefined,
|
||||
@@ -202,6 +198,21 @@ export function createRepackBuildFunction(): BuildFunction {
|
||||
});
|
||||
}
|
||||
|
||||
function createOutputPath({
|
||||
sourceAppPath,
|
||||
tmpDir,
|
||||
}: {
|
||||
sourceAppPath: string;
|
||||
tmpDir: string;
|
||||
}): string {
|
||||
const outputPath = path.join(tmpDir, `repacked-${randomUUID()}${path.extname(sourceAppPath)}`);
|
||||
const extension = path.extname(outputPath);
|
||||
if (extension.toLowerCase() !== '.aab') {
|
||||
return outputPath;
|
||||
}
|
||||
return `${outputPath.slice(0, -extension.length)}.apk`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Install `@expo/repack-app` in a sandbox directory and import it.
|
||||
*/
|
||||
|
||||
@@ -69,6 +69,10 @@ export function createRestoreCacheFunction(): BuildFunction {
|
||||
.filter(key => key !== '');
|
||||
|
||||
const jobId = nullthrows(env.EAS_BUILD_ID, 'EAS_BUILD_ID is not set');
|
||||
const expoApiServerURL = nullthrows(
|
||||
stepsCtx.global.staticContext.expoApiServerURL,
|
||||
'expoApiServerURL is not set'
|
||||
);
|
||||
const robotAccessToken = nullthrows(
|
||||
stepsCtx.global.staticContext.job.secrets?.robotAccessToken,
|
||||
'robotAccessToken is not set'
|
||||
@@ -77,7 +81,7 @@ export function createRestoreCacheFunction(): BuildFunction {
|
||||
const { archivePath, matchedKey } = await downloadCacheAsync({
|
||||
logger,
|
||||
jobId,
|
||||
expoApiServerURL: stepsCtx.global.staticContext.expoApiServerURL,
|
||||
expoApiServerURL,
|
||||
robotAccessToken,
|
||||
paths,
|
||||
key,
|
||||
|
||||
@@ -43,6 +43,10 @@ export function createSaveCacheFunction(): BuildFunction {
|
||||
.filter(path => path.length > 0);
|
||||
const key = z.string().parse(inputs.key.value);
|
||||
const jobId = nullthrows(env.EAS_BUILD_ID, 'EAS_BUILD_ID is not set');
|
||||
const expoApiServerURL = nullthrows(
|
||||
stepsCtx.global.staticContext.expoApiServerURL,
|
||||
'expoApiServerURL is not set'
|
||||
);
|
||||
const robotAccessToken = nullthrows(
|
||||
stepsCtx.global.staticContext.job.secrets?.robotAccessToken,
|
||||
'robotAccessToken is not set'
|
||||
@@ -61,7 +65,7 @@ export function createSaveCacheFunction(): BuildFunction {
|
||||
await uploadPublicCacheAsync({
|
||||
logger,
|
||||
jobId,
|
||||
expoApiServerURL: stepsCtx.global.staticContext.expoApiServerURL,
|
||||
expoApiServerURL,
|
||||
robotAccessToken,
|
||||
archivePath,
|
||||
key,
|
||||
@@ -73,7 +77,7 @@ export function createSaveCacheFunction(): BuildFunction {
|
||||
await uploadCacheAsync({
|
||||
logger,
|
||||
jobId,
|
||||
expoApiServerURL: stepsCtx.global.staticContext.expoApiServerURL,
|
||||
expoApiServerURL,
|
||||
robotAccessToken,
|
||||
archivePath,
|
||||
key,
|
||||
|
||||
@@ -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 },
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -31,6 +31,12 @@ export function createStartIosSimulatorBuildFunction(): BuildFunction {
|
||||
defaultValue: 1,
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.NUMBER,
|
||||
}),
|
||||
BuildStepInput.createProvider({
|
||||
id: 'enable_accessibility_settings',
|
||||
required: false,
|
||||
defaultValue: false,
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.BOOLEAN,
|
||||
}),
|
||||
],
|
||||
fn: async ({ logger }, { inputs, env }) => {
|
||||
try {
|
||||
@@ -55,11 +61,18 @@ export function createStartIosSimulatorBuildFunction(): BuildFunction {
|
||||
| undefined;
|
||||
const originalDeviceIdentifier =
|
||||
deviceIdentifierInput ?? (await findMostGenericIphoneUuidAsync({ env }));
|
||||
const enableAccessibilitySettings = Boolean(inputs.enable_accessibility_settings.value);
|
||||
|
||||
if (!originalDeviceIdentifier) {
|
||||
throw new Error('Could not find an iPhone among available simulator devices.');
|
||||
}
|
||||
|
||||
if (enableAccessibilitySettings) {
|
||||
await IosSimulatorUtils.enableAccessibilitySettingsAsync({
|
||||
deviceIdentifier: originalDeviceIdentifier,
|
||||
env,
|
||||
});
|
||||
}
|
||||
const { udid } = await IosSimulatorUtils.startAsync({
|
||||
deviceIdentifier: originalDeviceIdentifier,
|
||||
env,
|
||||
@@ -97,6 +110,12 @@ export function createStartIosSimulatorBuildFunction(): BuildFunction {
|
||||
env,
|
||||
});
|
||||
|
||||
if (enableAccessibilitySettings) {
|
||||
await IosSimulatorUtils.enableAccessibilitySettingsAsync({
|
||||
deviceIdentifier: cloneDeviceName,
|
||||
env,
|
||||
});
|
||||
}
|
||||
const { udid: cloneUdid } = await IosSimulatorUtils.startAsync({
|
||||
deviceIdentifier: cloneDeviceName,
|
||||
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 {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { ExpoError, SystemError, UserError } from '@expo/eas-build-job';
|
||||
import spawn, { SpawnPromise, SpawnResult } from '@expo/turtle-spawn';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
@@ -92,6 +93,67 @@ export namespace IosSimulatorUtils {
|
||||
});
|
||||
}
|
||||
|
||||
export async function enableAccessibilitySettingsAsync({
|
||||
deviceIdentifier,
|
||||
env,
|
||||
}: {
|
||||
deviceIdentifier: IosSimulatorUuid | IosSimulatorName;
|
||||
env: NodeJS.ProcessEnv;
|
||||
}): Promise<void> {
|
||||
try {
|
||||
const devices = await getAvailableDevicesAsync({ env, filter: 'available' });
|
||||
const device = devices.find(
|
||||
device =>
|
||||
device.isAvailable &&
|
||||
(device.udid === deviceIdentifier || device.name === deviceIdentifier)
|
||||
);
|
||||
if (!device) {
|
||||
throw new UserError(
|
||||
'EAS_IOS_SIMULATOR_NOT_FOUND',
|
||||
`Failed to find available iOS Simulator "${deviceIdentifier}" to update accessibility settings.`
|
||||
);
|
||||
}
|
||||
if (device.state !== 'Shutdown') {
|
||||
throw new UserError(
|
||||
'EAS_IOS_SIMULATOR_NOT_SHUTDOWN',
|
||||
`Expected iOS Simulator "${deviceIdentifier}" to be shutdown before updating accessibility settings, but it is ${device.state}.`
|
||||
);
|
||||
}
|
||||
|
||||
const plistPath = path.join(
|
||||
device.dataPath,
|
||||
'Library',
|
||||
'Preferences',
|
||||
'com.apple.Accessibility.plist'
|
||||
);
|
||||
await fs.promises.mkdir(path.dirname(plistPath), { recursive: true });
|
||||
|
||||
const plistExists = await fs.promises
|
||||
.access(plistPath)
|
||||
.then(() => true)
|
||||
.catch(() => false);
|
||||
if (!plistExists) {
|
||||
await spawn('plutil', ['-create', 'binary1', plistPath], { env });
|
||||
}
|
||||
|
||||
for (const key of [
|
||||
'AutomationEnabled',
|
||||
'IgnoreAXServerEntitlements',
|
||||
'AccessibilityEnabled',
|
||||
'ApplicationAccessibilityEnabled',
|
||||
]) {
|
||||
await spawn('plutil', ['-replace', key, '-bool', 'true', plistPath], { env });
|
||||
}
|
||||
} catch (err) {
|
||||
if (err instanceof ExpoError) {
|
||||
throw err;
|
||||
}
|
||||
throw new SystemError('Failed to update iOS Simulator accessibility settings.', {
|
||||
cause: err,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export async function startAsync({
|
||||
deviceIdentifier,
|
||||
env,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { SystemError, UserError } from '@expo/eas-build-job';
|
||||
import spawn from '@expo/turtle-spawn';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
@@ -18,9 +19,143 @@ const mockedSpawn = jest.mocked(spawn);
|
||||
|
||||
describe('IosSimulatorUtils', () => {
|
||||
beforeEach(() => {
|
||||
mockedSpawn.mockReset();
|
||||
mockedSpawn.mockResolvedValue({ stdout: '', stderr: '' } as any);
|
||||
});
|
||||
|
||||
describe(IosSimulatorUtils.startAsync, () => {
|
||||
it('waits for boot completion without writing accessibility prefs', async () => {
|
||||
mockedSpawn.mockImplementation((async (command: string, args: string[]) => {
|
||||
if (command === 'xcrun' && args.join(' ') === 'simctl bootstatus test-udid -b') {
|
||||
return {
|
||||
stdout: 'Monitoring boot status for iPhone 15 (test-udid).\n',
|
||||
stderr: '',
|
||||
} as any;
|
||||
}
|
||||
return { stdout: '', stderr: '' } as any;
|
||||
}) as any);
|
||||
|
||||
await IosSimulatorUtils.startAsync({
|
||||
deviceIdentifier: 'test-udid' as any,
|
||||
env: process.env,
|
||||
});
|
||||
|
||||
expect(mockedSpawn).not.toHaveBeenCalledWith(
|
||||
'xcrun',
|
||||
['simctl', 'list', 'devices', '--json', '--no-escape-slashes', 'test-udid'],
|
||||
expect.anything()
|
||||
);
|
||||
expect(mockedSpawn).not.toHaveBeenCalledWith('plutil', expect.anything(), expect.anything());
|
||||
});
|
||||
});
|
||||
|
||||
describe(IosSimulatorUtils.enableAccessibilitySettingsAsync, () => {
|
||||
function device(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
dataPath: '/tmp/eas-test-simulator-data',
|
||||
dataPathSize: 1,
|
||||
logPath: '/tmp/eas-test-simulator-logs',
|
||||
udid: 'test-udid',
|
||||
isAvailable: true,
|
||||
deviceTypeIdentifier: 'com.apple.CoreSimulator.SimDeviceType.iPhone-15',
|
||||
state: 'Shutdown',
|
||||
name: 'iPhone 15',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function mockAvailableDevices(devices: Record<string, unknown>[]) {
|
||||
mockedSpawn.mockImplementation((async (command: string, args: string[]) => {
|
||||
if (
|
||||
command === 'xcrun' &&
|
||||
args.join(' ') === 'simctl list devices --json --no-escape-slashes available'
|
||||
) {
|
||||
return {
|
||||
stdout: JSON.stringify({
|
||||
devices: {
|
||||
'com.apple.CoreSimulator.SimRuntime.iOS-18-0': devices,
|
||||
},
|
||||
}),
|
||||
stderr: '',
|
||||
} as any;
|
||||
}
|
||||
return { stdout: '', stderr: '' } as any;
|
||||
}) as any);
|
||||
}
|
||||
|
||||
it('writes accessibility prefs for an exact shutdown simulator match', async () => {
|
||||
mockAvailableDevices([
|
||||
device({
|
||||
udid: 'test-udid-pro',
|
||||
name: 'iPhone 17 Pro',
|
||||
dataPath: '/tmp/eas-test-simulator-data-pro',
|
||||
}),
|
||||
device({ name: 'iPhone 17' }),
|
||||
]);
|
||||
|
||||
await expect(
|
||||
IosSimulatorUtils.enableAccessibilitySettingsAsync({
|
||||
deviceIdentifier: 'iPhone 17' as any,
|
||||
env: process.env,
|
||||
})
|
||||
).resolves.toBeUndefined();
|
||||
|
||||
for (const key of [
|
||||
'AutomationEnabled',
|
||||
'IgnoreAXServerEntitlements',
|
||||
'AccessibilityEnabled',
|
||||
'ApplicationAccessibilityEnabled',
|
||||
]) {
|
||||
expect(mockedSpawn).toHaveBeenCalledWith(
|
||||
'plutil',
|
||||
[
|
||||
'-replace',
|
||||
key,
|
||||
'-bool',
|
||||
'true',
|
||||
'/tmp/eas-test-simulator-data/Library/Preferences/com.apple.Accessibility.plist',
|
||||
],
|
||||
{ env: process.env }
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it('throws UserError when no available simulator exactly matches the identifier', async () => {
|
||||
mockAvailableDevices([device({ name: 'iPhone 17 Pro' })]);
|
||||
|
||||
await expect(
|
||||
IosSimulatorUtils.enableAccessibilitySettingsAsync({
|
||||
deviceIdentifier: 'iPhone 17' as any,
|
||||
env: process.env,
|
||||
})
|
||||
).rejects.toThrow(UserError);
|
||||
|
||||
expect(mockedSpawn).not.toHaveBeenCalledWith('plutil', expect.anything(), expect.anything());
|
||||
});
|
||||
|
||||
it('throws UserError when the matched simulator is already booted', async () => {
|
||||
mockAvailableDevices([device({ state: 'Booted' })]);
|
||||
|
||||
await expect(
|
||||
IosSimulatorUtils.enableAccessibilitySettingsAsync({
|
||||
deviceIdentifier: 'test-udid' as any,
|
||||
env: process.env,
|
||||
})
|
||||
).rejects.toThrow(UserError);
|
||||
});
|
||||
|
||||
it('throws SystemError when pre-boot accessibility setup fails', async () => {
|
||||
mockedSpawn.mockRejectedValue(new Error('Failed to list simulators'));
|
||||
|
||||
await expect(
|
||||
IosSimulatorUtils.enableAccessibilitySettingsAsync({
|
||||
deviceIdentifier: 'test-udid' as any,
|
||||
env: process.env,
|
||||
})
|
||||
).rejects.toThrow(SystemError);
|
||||
});
|
||||
});
|
||||
|
||||
describe(IosSimulatorUtils.waitForReadyAsync, () => {
|
||||
it('takes the readiness screenshot into a writable temp file, not /dev/null', async () => {
|
||||
await IosSimulatorUtils.waitForReadyAsync({
|
||||
|
||||
@@ -104,4 +104,33 @@ describe(expoUpdates.configureExpoUpdatesIfInstalledAsync, () => {
|
||||
expect(iosSetChannelNativelyAsync).toBeCalledTimes(1);
|
||||
expect(getExpoUpdatesPackageVersionIfInstalledAsync).toBeCalledTimes(1);
|
||||
});
|
||||
|
||||
it('does not fail on missing EAS_BUILD_ID when logging fingerprint diffs', async () => {
|
||||
jest.mocked(getExpoUpdatesPackageVersionIfInstalledAsync).mockResolvedValue('0.18.0');
|
||||
|
||||
const warn = jest.fn();
|
||||
const managedCtx: BuildContext<BuildJob> = {
|
||||
appConfig: {},
|
||||
env: {},
|
||||
job: {
|
||||
platform: Platform.IOS,
|
||||
},
|
||||
logger: { warn },
|
||||
metadata: {
|
||||
runtimeVersion: 'runtime-version-from-local-machine',
|
||||
},
|
||||
getReactNativeProjectDirectory: () => '/app',
|
||||
} as any;
|
||||
|
||||
await expect(
|
||||
expoUpdates.configureExpoUpdatesIfInstalledAsync(managedCtx, {
|
||||
resolvedRuntimeVersion: 'runtime-version-from-eas-build',
|
||||
resolvedFingerprintSources: [],
|
||||
})
|
||||
).rejects.toThrow(
|
||||
'Runtime version calculated on local machine not equal to runtime version calculated during build.'
|
||||
);
|
||||
|
||||
expect(warn).toHaveBeenCalledWith('Skipping fingerprint diff because EAS_BUILD_ID is not set');
|
||||
});
|
||||
});
|
||||
@@ -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(
|
||||
|
||||
@@ -243,6 +243,12 @@ async function logDiffFingerprints({
|
||||
ctx: BuildContext<BuildJob>;
|
||||
}): Promise<void> {
|
||||
const { resolvedRuntimeVersion, resolvedFingerprintSources } = resolvedRuntime;
|
||||
const buildId = ctx.env.EAS_BUILD_ID;
|
||||
|
||||
if (!buildId) {
|
||||
ctx.logger.warn('Skipping fingerprint diff because EAS_BUILD_ID is not set');
|
||||
return;
|
||||
}
|
||||
|
||||
const fingerprintInfo = await ctx.graphqlClient
|
||||
.query(
|
||||
@@ -257,7 +263,7 @@ async function logDiffFingerprints({
|
||||
}
|
||||
}
|
||||
`),
|
||||
{ id: ctx.env.EAS_BUILD_ID }
|
||||
{ id: buildId }
|
||||
)
|
||||
.toPromise();
|
||||
|
||||
|
||||
@@ -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,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]>",
|
||||
|
||||
@@ -1,4 +1,18 @@
|
||||
import { StaticWorkflowInterpolationContextZ } from '../common';
|
||||
import { EnvSchema, StaticWorkflowInterpolationContextZ } from '../common';
|
||||
|
||||
describe('EnvSchema', () => {
|
||||
it('accepts explicit undefined values', () => {
|
||||
const env = {
|
||||
DEFINED_ENV: 'value',
|
||||
UNDEFINED_ENV: undefined,
|
||||
};
|
||||
|
||||
const { value, error } = EnvSchema.validate(env);
|
||||
|
||||
expect(error).toBeUndefined();
|
||||
expect(value).toEqual(env);
|
||||
});
|
||||
});
|
||||
|
||||
describe('StaticWorkflowInterpolationContextZ', () => {
|
||||
it('accepts app and account context', () => {
|
||||
|
||||
@@ -115,8 +115,8 @@ export const ArchiveSourceSchemaZ = z.discriminatedUnion('type', [
|
||||
}),
|
||||
]);
|
||||
|
||||
export type Env = Record<string, string>;
|
||||
export const EnvSchema = Joi.object().pattern(Joi.string(), Joi.string());
|
||||
export type Env = Record<string, string | undefined>;
|
||||
export const EnvSchema = Joi.object().pattern(Joi.string(), Joi.string().optional());
|
||||
|
||||
export type EnvironmentSecret = {
|
||||
name: string;
|
||||
|
||||
@@ -11,7 +11,7 @@ type StaticJobOnlyInterpolationContext = {
|
||||
outputs: Record<string, string | undefined>;
|
||||
}
|
||||
>;
|
||||
expoApiServerURL: string;
|
||||
expoApiServerURL: string | undefined;
|
||||
};
|
||||
|
||||
export type StaticJobInterpolationContext =
|
||||
|
||||
+114
-114
@@ -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 -->
|
||||
Generated
+377
-16
@@ -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",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "eas-cli",
|
||||
"version": "20.5.0",
|
||||
"version": "20.5.1",
|
||||
"description": "EAS command line tool",
|
||||
"keywords": [
|
||||
"cli",
|
||||
@@ -44,12 +44,12 @@
|
||||
"copy-new-templates": "rimraf build/commandUtils/new/templates && mkdir -p build/commandUtils/new && cp -r src/commandUtils/new/templates build/commandUtils/new"
|
||||
},
|
||||
"dependencies": {
|
||||
"@expo/apple-utils": "2.1.19",
|
||||
"@expo/apple-utils": "2.1.22",
|
||||
"@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();
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { ExpoConfig } from '@expo/config';
|
||||
import { Platform, Workflow } from '@expo/eas-build-job';
|
||||
import { Env, Platform, Workflow } from '@expo/eas-build-job';
|
||||
import { BuildProfile, EasJson } from '@expo/eas-json';
|
||||
import { LoggerLevel } from '@expo/logger';
|
||||
import { NodePackageManager } from '@expo/package-manager';
|
||||
@@ -65,5 +65,5 @@ export interface BuildContext<T extends Platform> {
|
||||
loggerLevel?: LoggerLevel;
|
||||
isVerboseLoggingEnabled: boolean;
|
||||
whatToTest?: string;
|
||||
env: Record<string, string>;
|
||||
env: Env;
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Platform } from '@expo/eas-build-job';
|
||||
import { Env, Platform } from '@expo/eas-build-job';
|
||||
import { BuildProfile, EasJson, ResourceClass } from '@expo/eas-json';
|
||||
import JsonFile from '@expo/json-file';
|
||||
import { LoggerLevel } from '@expo/logger';
|
||||
@@ -69,7 +69,7 @@ export async function createBuildContextAsync<T extends Platform>({
|
||||
refreshAdHocProvisioningProfile?: boolean;
|
||||
isVerboseLoggingEnabled: boolean;
|
||||
whatToTest?: string;
|
||||
env: Record<string, string>;
|
||||
env: Env;
|
||||
}): Promise<BuildContext<T>> {
|
||||
const { exp, projectId } = await getDynamicPrivateProjectConfigAsync({
|
||||
env,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Job, Metadata, version } from '@expo/eas-build-job';
|
||||
import { Env, Job, Metadata, version } from '@expo/eas-build-job';
|
||||
import spawnAsync from '@expo/spawn-async';
|
||||
import { ChildProcess } from 'child_process';
|
||||
import semver from 'semver';
|
||||
@@ -42,7 +42,7 @@ export async function runLocalBuildAsync(
|
||||
job: Job,
|
||||
metadata: Metadata,
|
||||
options: LocalBuildOptions,
|
||||
env: Record<string, string>
|
||||
env: Env
|
||||
): Promise<void> {
|
||||
const { command, args } = await getCommandAndArgsAsync(job, metadata);
|
||||
let spinner;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Platform, Workflow } from '@expo/eas-build-job';
|
||||
import { Env, Platform, Workflow } from '@expo/eas-build-job';
|
||||
|
||||
import { createFingerprintAsync } from './cli';
|
||||
import { Fingerprint } from './types';
|
||||
@@ -16,7 +16,7 @@ export async function getFingerprintInfoFromLocalProjectForPlatformsAsync(
|
||||
projectId: string,
|
||||
vcsClient: Client,
|
||||
platforms: AppPlatform[],
|
||||
{ env }: { env?: Record<string, string> } = {}
|
||||
{ env }: { env?: Env } = {}
|
||||
): Promise<Fingerprint> {
|
||||
const workflows = await resolveWorkflowPerPlatformAsync(projectDir, vcsClient);
|
||||
const optionsFromWorkflow = getFingerprintOptionsFromWorkflow(platforms, workflows);
|
||||
|
||||
+53
-6
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
import { testTarget } from '../../../credentials/__tests__/fixtures-ios';
|
||||
import { ApplePlatform } from '../../../credentials/ios/appstore/constants';
|
||||
import { Target } from '../../../credentials/ios/types';
|
||||
import { getApplePlatformFromDeviceFamily, getApplePlatformFromSdkRoot } from '../target';
|
||||
import {
|
||||
getApplePlatformFromDeviceFamily,
|
||||
getApplePlatformFromSdkRoot,
|
||||
getApplePlatformFromTarget,
|
||||
getManagedTvBuildSettings,
|
||||
} from '../target';
|
||||
|
||||
function getApplePlatformWithSdkRoot(sdkRoot: string): ApplePlatform | null {
|
||||
const target = {
|
||||
@@ -56,3 +61,64 @@ describe(getApplePlatformFromDeviceFamily, () => {
|
||||
expect(getApplePlatformWithDeviceFamily('ilovecats')).toBe(null);
|
||||
});
|
||||
});
|
||||
|
||||
describe(getManagedTvBuildSettings, () => {
|
||||
const originalExpoTv = process.env.EXPO_TV;
|
||||
afterEach(() => {
|
||||
if (originalExpoTv === undefined) {
|
||||
delete process.env.EXPO_TV;
|
||||
} else {
|
||||
process.env.EXPO_TV = originalExpoTv;
|
||||
}
|
||||
});
|
||||
|
||||
test('returns undefined when EXPO_TV is not set in env or process.env', () => {
|
||||
delete process.env.EXPO_TV;
|
||||
expect(getManagedTvBuildSettings(undefined)).toBeUndefined();
|
||||
expect(getManagedTvBuildSettings({})).toBeUndefined();
|
||||
expect(getManagedTvBuildSettings({ EXPO_TV: '' })).toBeUndefined();
|
||||
expect(getManagedTvBuildSettings({ EXPO_TV: '0' })).toBeUndefined();
|
||||
expect(getManagedTvBuildSettings({ EXPO_TV: 'false' })).toBeUndefined();
|
||||
expect(getManagedTvBuildSettings({ EXPO_TV: 'no' })).toBeUndefined();
|
||||
});
|
||||
|
||||
test('returns TARGETED_DEVICE_FAMILY=3 when EXPO_TV is truthy in the env parameter (eas.json case)', () => {
|
||||
delete process.env.EXPO_TV;
|
||||
for (const value of ['1', 'true', 'yes', 'TRUE', 'Yes']) {
|
||||
expect(getManagedTvBuildSettings({ EXPO_TV: value })).toEqual({
|
||||
TARGETED_DEVICE_FAMILY: '3',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test('falls back to process.env when env is missing EXPO_TV (shell case)', () => {
|
||||
process.env.EXPO_TV = '1';
|
||||
expect(getManagedTvBuildSettings(undefined)).toEqual({ TARGETED_DEVICE_FAMILY: '3' });
|
||||
expect(getManagedTvBuildSettings({})).toEqual({ TARGETED_DEVICE_FAMILY: '3' });
|
||||
});
|
||||
|
||||
test('env-parameter EXPO_TV takes precedence over process.env (eas.json overrides shell)', () => {
|
||||
process.env.EXPO_TV = '0';
|
||||
expect(getManagedTvBuildSettings({ EXPO_TV: '1' })).toEqual({
|
||||
TARGETED_DEVICE_FAMILY: '3',
|
||||
});
|
||||
|
||||
process.env.EXPO_TV = '1';
|
||||
expect(getManagedTvBuildSettings({ EXPO_TV: '0' })).toBeUndefined();
|
||||
});
|
||||
|
||||
test('feeds into getApplePlatformFromTarget so managed tvOS targets resolve to TV_OS', () => {
|
||||
delete process.env.EXPO_TV;
|
||||
const target: Target = {
|
||||
...testTarget,
|
||||
buildSettings: getManagedTvBuildSettings({ EXPO_TV: '1' }),
|
||||
};
|
||||
expect(getApplePlatformFromTarget(target)).toBe(ApplePlatform.TV_OS);
|
||||
|
||||
const iosTarget: Target = {
|
||||
...testTarget,
|
||||
buildSettings: getManagedTvBuildSettings({}),
|
||||
};
|
||||
expect(getApplePlatformFromTarget(iosTarget)).toBe(ApplePlatform.IOS);
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,5 @@
|
||||
import { ExportedConfig, IOSConfig, compileModsAsync } from '@expo/config-plugins';
|
||||
import { Env } from '@expo/eas-build-job';
|
||||
import { JSONObject } from '@expo/json-file';
|
||||
import { getPrebuildConfigAsync } from '@expo/prebuild-config';
|
||||
|
||||
@@ -17,7 +18,7 @@ interface Target {
|
||||
|
||||
export async function getManagedApplicationTargetEntitlementsAsync(
|
||||
projectDir: string,
|
||||
env: Record<string, string>,
|
||||
env: Env,
|
||||
vcsClient: Client
|
||||
): Promise<JSONObject> {
|
||||
let expoConfigError: any;
|
||||
@@ -95,7 +96,7 @@ export async function getManagedApplicationTargetEntitlementsAsync(
|
||||
|
||||
async function resolveManagedApplicationTargetEntitlementsWithBundledConfigAsync(
|
||||
projectDir: string,
|
||||
env: Record<string, string>,
|
||||
env: Env,
|
||||
vcsClient: Client
|
||||
): Promise<JSONObject> {
|
||||
const originalProcessEnv = process.env;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { ExpoConfig } from '@expo/config';
|
||||
import { IOSConfig, XcodeProject } from '@expo/config-plugins';
|
||||
import { Platform, Workflow } from '@expo/eas-build-job';
|
||||
import { Env, Platform, Workflow } from '@expo/eas-build-job';
|
||||
import { JSONObject } from '@expo/json-file';
|
||||
import Joi from 'joi';
|
||||
import type { XCBuildConfiguration } from 'xcode';
|
||||
@@ -26,7 +26,7 @@ interface UserDefinedTarget {
|
||||
interface ResolveTargetOptions {
|
||||
projectDir: string;
|
||||
exp: ExpoConfig;
|
||||
env?: Record<string, string>;
|
||||
env?: Env;
|
||||
xcodeBuildContext: XcodeBuildContext;
|
||||
vcsClient: Client;
|
||||
}
|
||||
@@ -40,6 +40,28 @@ const AppExtensionsConfigSchema = Joi.array().items(
|
||||
})
|
||||
);
|
||||
|
||||
/**
|
||||
* For managed projects we don't have an Xcode project to read build settings
|
||||
* from, but the @react-native-tvos/config-tv plugin signals a tvOS build via
|
||||
* the EXPO_TV environment variable. That variable can come from the build
|
||||
* profile's `env` in eas.json (the typical case, which flows in via `env`),
|
||||
* or from the shell process when the user runs `EXPO_TV=1 eas build ...`
|
||||
* (which is NOT included in the merged `env` upstream, so we read
|
||||
* `process.env` directly as a fallback). When set, synthesize
|
||||
* TARGETED_DEVICE_FAMILY=3 so getApplePlatformFromTarget (and every
|
||||
* Apple-portal flow downstream) picks TV_OS for these targets instead of
|
||||
* defaulting to IOS.
|
||||
*/
|
||||
export function getManagedTvBuildSettings(
|
||||
env: Env | undefined
|
||||
): Target['buildSettings'] | undefined {
|
||||
const expoTv = (env?.EXPO_TV ?? process.env.EXPO_TV ?? '').toString().toLowerCase();
|
||||
if (expoTv === '1' || expoTv === 'true' || expoTv === 'yes') {
|
||||
return { TARGETED_DEVICE_FAMILY: '3' };
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export async function resolveManagedProjectTargetsAsync({
|
||||
exp,
|
||||
projectDir,
|
||||
@@ -76,12 +98,14 @@ export async function resolveManagedProjectTargetsAsync({
|
||||
);
|
||||
}
|
||||
|
||||
const managedBuildSettings = getManagedTvBuildSettings(env);
|
||||
const extensionsTargets: Target[] = appExtensions.map(extension => ({
|
||||
targetName: extension.targetName,
|
||||
buildConfiguration,
|
||||
bundleIdentifier: extension.bundleIdentifier,
|
||||
parentBundleIdentifier: extension.parentBundleIdentifier ?? applicationTargetBundleIdentifier,
|
||||
entitlements: extension.entitlements ?? {},
|
||||
...(managedBuildSettings && { buildSettings: managedBuildSettings }),
|
||||
}));
|
||||
return [
|
||||
{
|
||||
@@ -89,6 +113,7 @@ export async function resolveManagedProjectTargetsAsync({
|
||||
bundleIdentifier: applicationTargetBundleIdentifier,
|
||||
buildConfiguration,
|
||||
entitlements: applicationTargetEntitlements,
|
||||
...(managedBuildSettings && { buildSettings: managedBuildSettings }),
|
||||
},
|
||||
...extensionsTargets,
|
||||
];
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { ExpoConfig } from '@expo/config';
|
||||
import { Platform } from '@expo/eas-build-job';
|
||||
import { Env, Platform } from '@expo/eas-build-job';
|
||||
import { SubmitProfile } from '@expo/eas-json';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
@@ -47,7 +47,7 @@ export interface SubmitArchiveFlags {
|
||||
export async function createSubmissionContextAsync<T extends Platform>(params: {
|
||||
archiveFlags: SubmitArchiveFlags;
|
||||
credentialsCtx?: CredentialsContext;
|
||||
env?: Record<string, string>;
|
||||
env?: Env;
|
||||
nonInteractive: boolean;
|
||||
isVerboseFastlaneEnabled: boolean;
|
||||
groups: string[] | undefined;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
@@ -268,8 +268,8 @@ describe(getBuildEnv.name, () => {
|
||||
buildId: 'build-id',
|
||||
});
|
||||
|
||||
expect(env.NPM_CACHE_URL).toBe('https://npm.example');
|
||||
expect(env.NPM_CONFIG_REGISTRY).toBe('https://npm.example');
|
||||
expect(env.NPM_CACHE_URL).toBeUndefined();
|
||||
expect(env.NPM_CONFIG_REGISTRY).toBeUndefined();
|
||||
expect(env.EAS_BUILD_NPM_CACHE_URL).toBe('https://npm.example');
|
||||
expect(env.NVM_NODEJS_ORG_MIRROR).toBe('https://node.example');
|
||||
expect(env.EAS_BUILD_MAVEN_CACHE_URL).toBe('https://maven.example');
|
||||
|
||||
@@ -70,24 +70,30 @@ function printImageDescription(ctx: BuildContext<Job>): void {
|
||||
function printEnvs(ctx: BuildContext<Job>): void {
|
||||
const { logger, job } = ctx;
|
||||
const publicEnv: Record<string, string> = {};
|
||||
const secretEnv: Record<string, string> = {};
|
||||
// We don't expect environment secrets to be missing from ctx.env,
|
||||
// but if they do we want to see it in logs as "=undefined".
|
||||
const secretEnv: Record<string, string | undefined> = {};
|
||||
const instanceEnv: Record<string, string> = {};
|
||||
|
||||
// skip development and testing to avoid leaking local credentials from envs to bucket
|
||||
if (config.env !== Environment.DEVELOPMENT && config.env !== Environment.TEST) {
|
||||
Object.entries(ctx.env).forEach(([key, value]) => {
|
||||
instanceEnv[key] = value;
|
||||
if (value !== undefined) {
|
||||
instanceEnv[key] = value;
|
||||
}
|
||||
});
|
||||
}
|
||||
Object.entries(job.builderEnvironment?.env ?? ({} as Record<string, string>)).forEach(
|
||||
([key, value]) => {
|
||||
Object.entries(job.builderEnvironment?.env ?? {}).forEach(([key, value]) => {
|
||||
if (value !== undefined) {
|
||||
publicEnv[key] = value;
|
||||
delete instanceEnv[key];
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
job.secrets?.environmentSecrets?.forEach(({ name, type }) => {
|
||||
if (type === EnvironmentSecretType.FILE) {
|
||||
// ctx.env[name] for a FILE-typed environment secret
|
||||
// should be a path to the file.
|
||||
secretEnv[name] = ctx.env[name];
|
||||
} else {
|
||||
secretEnv[name] = '********';
|
||||
|
||||
@@ -36,14 +36,11 @@ export function getBuildEnv({
|
||||
setEnv(env, 'EAS_BUILD_RUNNER', 'eas-build');
|
||||
setEnv(env, 'EAS_BUILD_PLATFORM', job.platform);
|
||||
setEnv(env, 'EAS_CLI_SENTRY_DSN', config.sentry.dsn);
|
||||
// NPM_CACHE_URL is deprecated
|
||||
const npmCacheUrl = RuntimeSettings.getNpmCacheUrl();
|
||||
const nodeJsCacheUrl = RuntimeSettings.getNodeJsCacheUrl();
|
||||
const mavenCacheUrl = RuntimeSettings.getMavenCacheUrl();
|
||||
const cocoapodsCacheUrl = RuntimeSettings.getCocoapodsCacheUrl();
|
||||
|
||||
setEnv(env, 'NPM_CACHE_URL', npmCacheUrl);
|
||||
setEnv(env, 'NPM_CONFIG_REGISTRY', npmCacheUrl);
|
||||
setEnv(env, 'NVM_NODEJS_ORG_MIRROR', nodeJsCacheUrl);
|
||||
setEnv(env, 'EAS_BUILD_NPM_CACHE_URL', npmCacheUrl);
|
||||
setEnv(env, 'EAS_BUILD_PROFILE', metadata.buildProfile);
|
||||
|
||||
@@ -2847,23 +2847,23 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@expo/apple-utils@npm:2.1.19":
|
||||
version: 2.1.19
|
||||
resolution: "@expo/apple-utils@npm:2.1.19"
|
||||
"@expo/apple-utils@npm:2.1.22":
|
||||
version: 2.1.22
|
||||
resolution: "@expo/apple-utils@npm:2.1.22"
|
||||
bin:
|
||||
apple-utils: bin.js
|
||||
checksum: 10c0/42eb1038bd0906d53b37d830499c7a8ee7ec9f9b9ef4078ff898ca84cb5af1e6ac39d3a392818825151aa2ea237a44698f539b973936733350f314efff020858
|
||||
checksum: 10c0/19a5d21f80cde11e7502d7380b17bb4634568dcd243eb72ce741277dd71aab2b20168e44060143b10bfa01f0b03a6b8a2628b3cf3e5d697b12cf7f78a16d469a
|
||||
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"
|
||||
@@ -11154,12 +11154,12 @@ __metadata:
|
||||
version: 0.0.0-use.local
|
||||
resolution: "eas-cli@workspace:packages/eas-cli"
|
||||
dependencies:
|
||||
"@expo/apple-utils": "npm:2.1.19"
|
||||
"@expo/apple-utils": "npm:2.1.22"
|
||||
"@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"
|
||||
|
||||
Reference in new issue
Block a user