Skip to content
Merged
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
47 changes: 47 additions & 0 deletions decisions/2026-07-05-asc-api-key-provisioning.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
# App Store Connect API key for headless provisioning

- Date: 2026-07-05
- Status: accepted

## Context

`mix mob.provision` authenticates `xcodebuild -allowProvisioningUpdates` against
Apple using the **signed-in Xcode Apple ID account** (Xcode → Settings →
Accounts). That account is per-macOS-user and only settable through Xcode's GUI,
so an unattended user — a CI runner, or an isolated headless *agent* account with
no GUI login — can register the signing identity but cannot provision (create /
refresh profiles, register devices). The cert + private key path already works
headlessly (a keychain the codesign step can read); only the Apple-contact step
was gated on the interactive account.

## Decision

Support an **App Store Connect API key** (`.p8`) as an alternative auth path for
the `xcodebuild -allowProvisioningUpdates` call, selected via three env vars:

- `MOB_ASC_KEY_ID`, `MOB_ASC_ISSUER_ID`, `MOB_ASC_KEY_PATH`

`asc_auth_args/1` (pure, `@doc false`, tested) maps them to xcodebuild's
`-authenticationKeyID` / `-authenticationKeyIssuerID` / `-authenticationKeyPath`.

- **Env vars, not a flag or mob.exs.** The key is a secret + machine-specific; env
keeps it out of args history and out of the repo, and is the natural fit for a
headless account's shell/launchd environment (and standard CI practice).
- **All three or none; partial raises.** A half-set key silently falling back to
account auth would be a confusing "why is it still asking for Xcode?" — so a
partial set is surfaced as an error naming what's missing.
- **Scoped to `mob.provision` only.** The native device build signs directly with
`codesign` + an existing profile (no `-allowProvisioningUpdates`), so it needs
only the keychain + profile, not the API key. Nothing else to thread it through.
- **Early `.p8` existence check** — clearer than an opaque xcodebuild failure.

## Consequences

- Unattended users provision by exporting the signing identity into an unlocked
keychain + setting the three env vars — no Xcode GUI account. Interactive users
are unaffected (none set ⇒ prior account-based behavior).
- The API key must have a role that can manage certificates/profiles/devices
(Admin or App Manager). Signing still requires the cert + private key in an
unlocked keychain — the key only authorizes the Apple-contact step.
- Follow-up: the runtime console preamble still prints "Xcode signed in" as step
2; could branch on the env vars to show the API-key path instead (cosmetic).
114 changes: 104 additions & 10 deletions lib/mix/tasks/mob.provision.ex
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,25 @@ defmodule Mix.Tasks.Mob.Provision do

Distribution mode requires a paid Developer Program membership.

## Headless / unattended provisioning (App Store Connect API key)

Step 2 (an interactive Xcode Apple ID account) is impossible for an unattended
user — a CI runner or a headless agent account with no GUI login. Instead,
authenticate `-allowProvisioningUpdates` with an **App Store Connect API key**
by setting three env vars; when they are present the task passes them to
`xcodebuild` and no signed-in Xcode account is needed:

* `MOB_ASC_KEY_ID` — the API key's Key ID
* `MOB_ASC_ISSUER_ID` — your team's Issuer ID
* `MOB_ASC_KEY_PATH` — path to the downloaded `AuthKey_<id>.p8`

Create the key at App Store Connect → Users and Access → Integrations → App
Store Connect API, with a role that can manage certificates/profiles/devices
(Admin or App Manager). The `.p8` downloads once — store it read-only. Set all
three or none (a partial set raises); with none set, the signed-in Xcode
account is used as before. Signing still needs the certificate + private key in
an unlocked keychain — the API key only authorizes the profile/device calls.

## What it does (development)

1. Reads your signing team from the macOS keychain or existing profiles
Expand Down Expand Up @@ -398,22 +417,97 @@ defmodule Mix.Tasks.Mob.Provision do

# ── xcodebuild ────────────────────────────────────────────────────────────────

@asc_key_id "MOB_ASC_KEY_ID"
@asc_issuer_id "MOB_ASC_ISSUER_ID"
@asc_key_path "MOB_ASC_KEY_PATH"

@doc false
# App Store Connect API-key auth flags for xcodebuild, from env. Lets an
# unattended user (a headless agent / CI) provision without an interactive
# Xcode Apple ID account: with the key set, `-allowProvisioningUpdates`
# authenticates against App Store Connect directly. Returns `[]` when none of
# the vars are set (falls back to the signed-in Xcode account, the default).
# Raises on partial config — a half-set key is a mistake worth surfacing, not
# a silent fall-back to a different auth path.
#
# * `MOB_ASC_KEY_ID` — the API key's Key ID
# * `MOB_ASC_ISSUER_ID` — your team's Issuer ID
# * `MOB_ASC_KEY_PATH` — path to the downloaded `AuthKey_<id>.p8`
@spec asc_auth_args(map()) :: [String.t()]
def asc_auth_args(env) do
id = present(env, @asc_key_id)
issuer = present(env, @asc_issuer_id)
path = present(env, @asc_key_path)

cond do
is_nil(id) and is_nil(issuer) and is_nil(path) ->
[]

is_binary(id) and is_binary(issuer) and is_binary(path) ->
[
"-authenticationKeyID",
id,
"-authenticationKeyIssuerID",
issuer,
"-authenticationKeyPath",
path
]

true ->
set =
for {v, k} <- [{id, @asc_key_id}, {issuer, @asc_issuer_id}, {path, @asc_key_path}],
v,
do: k

missing = Enum.reject([@asc_key_id, @asc_issuer_id, @asc_key_path], &(&1 in set))

Mix.raise("""
Incomplete App Store Connect API key config.

Set #{Enum.join(set, ", ")} but missing #{Enum.join(missing, ", ")}.
Provide all three (#{@asc_key_id}, #{@asc_issuer_id}, #{@asc_key_path}) to
provision via an API key, or none to use the signed-in Xcode account.
""")
end
end

# nil for an unset OR empty env var, so `MOB_ASC_KEY_ID=` counts as absent.
defp present(env, key) do
case Map.get(env, key) do
v when is_binary(v) and v != "" -> v
_ -> nil
end
end

# Fail early with a clear message rather than an opaque xcodebuild error when
# the key file is missing — the common headless-setup slip.
defp verify_asc_key_file!(path) when is_binary(path) and path != "" do
unless File.exists?(path) do
Mix.raise("#{@asc_key_path} points to a file that does not exist: #{path}")
end
end

defp verify_asc_key_file!(_), do: :ok

defp run_xcodebuild!(mode) do
# `-scheme MobProvision` rather than `-target MobProvision`: Xcode 16+
# rejects `-archivePath` paired with `-target` ("The flag -scheme is
# required when specifying -archivePath but not -exportArchive").
# Both forms work for the build action, so we use scheme for both
# to keep the invocation consistent.
base = [
"-project",
"ios/Provision.xcodeproj",
"-scheme",
"MobProvision",
"-destination",
"generic/platform=iOS",
"-allowProvisioningUpdates",
"-allowProvisioningDeviceRegistration"
]
base =
[
"-project",
"ios/Provision.xcodeproj",
"-scheme",
"MobProvision",
"-destination",
"generic/platform=iOS",
"-allowProvisioningUpdates",
"-allowProvisioningDeviceRegistration"
] ++ asc_auth_args(System.get_env())

verify_asc_key_file!(System.get_env(@asc_key_path))

args =
case mode do
Expand Down
45 changes: 45 additions & 0 deletions test/mix/tasks/mob_provision_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -151,4 +151,49 @@ defmodule Mix.Tasks.Mob.ProvisionTest do
assert label =~ "App ID display name"
end
end

# ── asc_auth_args/1 — headless provisioning via App Store Connect API key ────
describe "asc_auth_args/1" do
test "no env vars set => [] (falls back to the signed-in Xcode account)" do
assert Provision.asc_auth_args(%{}) == []
assert Provision.asc_auth_args(%{"UNRELATED" => "x"}) == []
end

test "all three set => the three xcodebuild -authenticationKey* flags, in order" do
env = %{
"MOB_ASC_KEY_ID" => "ABC123",
"MOB_ASC_ISSUER_ID" => "69a6de00-1234",
"MOB_ASC_KEY_PATH" => "/keys/AuthKey_ABC123.p8"
}

assert Provision.asc_auth_args(env) == [
"-authenticationKeyID",
"ABC123",
"-authenticationKeyIssuerID",
"69a6de00-1234",
"-authenticationKeyPath",
"/keys/AuthKey_ABC123.p8"
]
end

test "empty-string values count as absent (KEY_ID= is the same as unset)" do
assert Provision.asc_auth_args(%{
"MOB_ASC_KEY_ID" => "",
"MOB_ASC_ISSUER_ID" => "",
"MOB_ASC_KEY_PATH" => ""
}) == []
end

test "partial config raises, naming what's set and what's missing" do
err =
assert_raise Mix.Error, fn ->
Provision.asc_auth_args(%{"MOB_ASC_KEY_ID" => "ABC123"})
end

assert err.message =~ "Incomplete App Store Connect API key config"
assert err.message =~ "MOB_ASC_KEY_ID"
assert err.message =~ "MOB_ASC_ISSUER_ID"
assert err.message =~ "MOB_ASC_KEY_PATH"
end
end
end
Loading