From af417c9f2766a4895b45f6a36e4f16ff96c5aa34 Mon Sep 17 00:00:00 2001 From: GenericJam Date: Sun, 5 Jul 2026 00:04:57 -0600 Subject: [PATCH] mob.provision: App Store Connect API key for headless provisioning MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit mix mob.provision authenticated xcodebuild -allowProvisioningUpdates via the signed-in Xcode Apple ID account, which is per-user + GUI-only — so an unattended user (CI, or a headless agent account with no GUI login) could register the signing identity but never provision. Add an App Store Connect API key path selected by env: MOB_ASC_KEY_ID / MOB_ASC_ISSUER_ID / MOB_ASC_KEY_PATH → xcodebuild -authenticationKeyID / -authenticationKeyIssuerID / -authenticationKeyPath. Pure asc_auth_args/1 (tested): none set => [] (account auth, unchanged); all three => the flags; partial => raises naming what's missing; empty string counts as unset. Early .p8 existence check for a clear error. Scoped to mob.provision only — the device build signs directly with codesign + an existing profile, so it needs no key. Docs: moduledoc "Headless / unattended provisioning" section. ADR: decisions/2026-07-05-asc-api-key-provisioning.md. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../2026-07-05-asc-api-key-provisioning.md | 47 ++++++++ lib/mix/tasks/mob.provision.ex | 114 ++++++++++++++++-- test/mix/tasks/mob_provision_test.exs | 45 +++++++ 3 files changed, 196 insertions(+), 10 deletions(-) create mode 100644 decisions/2026-07-05-asc-api-key-provisioning.md diff --git a/decisions/2026-07-05-asc-api-key-provisioning.md b/decisions/2026-07-05-asc-api-key-provisioning.md new file mode 100644 index 0000000..423d146 --- /dev/null +++ b/decisions/2026-07-05-asc-api-key-provisioning.md @@ -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). diff --git a/lib/mix/tasks/mob.provision.ex b/lib/mix/tasks/mob.provision.ex index fa39851..3bfaec5 100644 --- a/lib/mix/tasks/mob.provision.ex +++ b/lib/mix/tasks/mob.provision.ex @@ -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_.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 @@ -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_.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 diff --git a/test/mix/tasks/mob_provision_test.exs b/test/mix/tasks/mob_provision_test.exs index a453f29..313f7e3 100644 --- a/test/mix/tasks/mob_provision_test.exs +++ b/test/mix/tasks/mob_provision_test.exs @@ -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