Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 19 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,22 @@ jobs:
platform: ${{ matrix.platform }}
```

When you need a specific simulator, supply `destination` with a device name or
UDID. Device names support an optional OS version in parentheses, allowing you
to stay resilient while targeting the right runtime:

```yaml
jobs:
build:
runs-on: macos-latest
steps:
- uses: actions/checkout@v4
- uses: mxcl/xcodebuild@v3
with:
platform: iOS
destination: iPhone 15 Pro (17.4)
```

```yaml
jobs:
build:
Expand Down Expand Up @@ -339,9 +355,10 @@ jobs:

- We’re smart based on the selected Xcode version, for example we know watchOS
cannot be tested prior to 12.5 and run xcodebuild with `build` instead
- We figure out the the simulator destination for you automatically. Stop
- We figure out the simulator destination for you automatically. Stop
specifying fragile strings like `platform=iphonesimulator,os=14.5,name=iPhone 12`
that will break when Xcode updates next week.
that will break when Xcode updates next week. When you do need a specific
simulator, use the `destination` input with a device name or UDID.
- You probably don’t need to specify project or scheme since we aren’t tedious
if possible
- `warnings-as-errors` is only applied to normal targets: not your tests
Expand Down
6 changes: 6 additions & 0 deletions action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,12 @@ inputs:
Either `arm64` `x86_64 `i386`
Leave unset and `xcodebuild` decides itself.
required: false
destination:
description: |
Simulator device to target. Provide a device name (eg. `iPhone 15 Pro (16.4)`) or a
device UDID to override automatic selection. Supported for iOS, tvOS,
watchOS and visionOS platforms.
required: false
action:
description: |
* The most common actions are `test`, `build`.
Expand Down
2 changes: 1 addition & 1 deletion dist/index.js

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion dist/index.js.map

Large diffs are not rendered by default.

8 changes: 7 additions & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,13 @@ async function main() {
const action = getAction(selected, platform)
const configuration = getConfiguration()
const warningsAsErrors = core.getBooleanInput('warnings-as-errors')
const destination = await getDestination(selected, platform, platformVersion)
const explicitDestination = core.getInput('destination')
const destination = await getDestination(
selected,
platform,
platformVersion,
explicitDestination
)
const identity = getIdentity(core.getInput('code-sign-identity'), platform)
const currentVerbosity = verbosity()
const workspace = core.getInput('workspace')
Expand Down
217 changes: 186 additions & 31 deletions src/lib.ts
Original file line number Diff line number Diff line change
Expand Up @@ -166,20 +166,22 @@ export async function xcselect(xcode?: Range, swift?: Range): Promise<SemVer> {

interface Devices {
devices: {
[key: string]: [
{
udid: string
name: string
}
]
[key: string]: SimulatorDevice[]
}
}

interface SimulatorDevice {
udid: string
name: string
isAvailable?: boolean
}

type DeviceType = 'watchOS' | 'tvOS' | 'iOS' | 'xrOS'
type Destination = {
id: string
name: string | undefined
version: SemVer
type: DeviceType
}

interface Schemes {
Expand Down Expand Up @@ -225,41 +227,168 @@ function parseJSON<T>(input: string): T {
}
}

async function destination(
deviceType: DeviceType,
version?: Range
): Promise<Destination | undefined> {
async function availableDestinations(): Promise<Destination[]> {
const devices = await fetchSimulators()
const results: Destination[] = []

for (const identifier in devices) {
const [type, version] = parseRuntimeIdentifier(identifier)
if (!type || !version) continue

for (const device of devices[identifier] ?? []) {
if (!device) continue
if (device.isAvailable === false) continue

results.push({
id: device.udid,
name: device.name,
version,
type,
})
}
}

return results
}

async function fetchSimulators(): Promise<Devices['devices']> {
const out = await exec('xcrun', [
'simctl',
'list',
'--json',
'devices',
'available',
])
const devices = parseJSON<Devices>(out).devices

// best match
let bm: Destination | undefined
for (const opaqueIdentifier in devices) {
const device = (devices[opaqueIdentifier] ?? [])[0]
if (!device) continue
const [type, v] = parse(opaqueIdentifier)
if (
v &&
type === deviceType &&
(!version || version.test(v)) &&
(!bm || semver.lt(bm.version, v))
) {
bm = { id: device.udid, name: device.name, version: v }
return parseJSON<Devices>(out).devices
}

function parseRuntimeIdentifier(
key: string
): [DeviceType | undefined, SemVer | undefined] {
const [type, ...vv] = (key.split('.').pop() ?? '').split('-')
const version = semver.coerce(vv.join('.')) ?? undefined
return [toDeviceType(type), version]
}

function toDeviceType(type: string | undefined): DeviceType | undefined {
switch (type) {
case 'iOS':
case 'tvOS':
case 'watchOS':
case 'xrOS':
return type
default:
return undefined
}
}

async function destination(
deviceType: DeviceType,
version?: Range
): Promise<Destination | undefined> {
const devices = await fetchSimulators()

let bestMatch: Destination | undefined

for (const identifier in devices) {
const [type, runtimeVersion] = parseRuntimeIdentifier(identifier)
if (!type || type !== deviceType) continue
if (!runtimeVersion) continue
if (version && !version.test(runtimeVersion)) continue

const candidates = (devices[identifier] ?? []).filter(
(device) => device && device.isAvailable !== false
)
if (candidates.length === 0) continue

const device = candidates[0]
const candidate: Destination = {
id: device.udid,
name: device.name,
version: runtimeVersion,
type,
}

if (!bestMatch || semver.lt(bestMatch.version, runtimeVersion)) {
bestMatch = candidate
}
}

return bm
return bestMatch
}

function parse(key: string): [DeviceType, SemVer?] {
const [type, ...vv] = (key.split('.').pop() ?? '').split('-')
const v = semver.coerce(vv.join('.'))
return [type as DeviceType, v ?? undefined]
async function resolveManualDestination(
input: string,
platform?: Platform,
platformVersion?: Range
): Promise<Destination | undefined> {
const destinations = await availableDestinations()
const trimmed = input.trim()
if (!trimmed) return undefined

const allowedTypes = platformToDeviceTypes(platform)
const matchesFilters = (dest: Destination) => {
if (allowedTypes && !allowedTypes.includes(dest.type)) return false
if (platformVersion && !platformVersion.test(dest.version)) return false
return true
}

if (looksLikeUDID(trimmed)) {
const normalized = trimmed.toLowerCase()
return destinations.find(
(dest) => dest.id.toLowerCase() === normalized && matchesFilters(dest)
)
}

const { name, version } = parseManualDestination(trimmed)

const matches = destinations.filter((dest) => {
if (!dest.name) return false
if (dest.name.toLowerCase() !== name.toLowerCase()) return false
if (!matchesFilters(dest)) return false
if (version && !semver.eq(dest.version, version)) return false
return true
})

if (matches.length === 0) return undefined

matches.sort((a, b) => semver.compare(a.version, b.version))
return matches.pop()
}

function parseManualDestination(input: string): {
name: string
version?: SemVer
} {
const match = input.match(/^(.*?)(?:\s*\((.+)\))?$/)
const name = (match?.[1] ?? input).trim()
const versionRaw = match?.[2]?.trim()
const version = versionRaw
? semver.coerce(versionRaw) ?? undefined
: undefined
return { name, version }
}

function looksLikeUDID(value: string): boolean {
const normalized = value.trim()
return (
/^[0-9A-Fa-f-]{32,36}$/.test(normalized) &&
normalized.replace(/-/g, '').length >= 25
)
}

function platformToDeviceTypes(platform?: Platform): DeviceType[] | undefined {
switch (platform) {
case 'iOS':
return ['iOS']
case 'tvOS':
return ['tvOS']
case 'watchOS':
return ['watchOS']
case 'visionOS':
return ['xrOS']
default:
return undefined
}
}

Expand Down Expand Up @@ -364,8 +493,34 @@ export function actionIsTestable(action?: string): boolean {
export async function getDestination(
xcodeVersion: SemVer,
platform?: Platform,
platformVersion?: Range
platformVersion?: Range,
manualDestination?: string
): Promise<string[]> {
const trimmedDestination = manualDestination?.trim()

if (trimmedDestination) {
if (platform === 'macOS' || platform === 'mac-catalyst') {
throw new Error(
'`destination` is only supported for simulator-based platforms.'
)
}

const dest = await resolveManualDestination(
trimmedDestination,
platform,
platformVersion
)

if (!dest) {
throw new Error(
`Device not found for destination '${trimmedDestination}'.`
)
}

core.info(`Selected device: ${dest.name ?? dest.id} (${dest.version})`)
return ['-destination', `id=${dest.id}`]
}

switch (platform) {
case 'iOS':
case 'tvOS':
Expand Down