Skip to content
C.W.K.
Stream
Lesson 03 of 07 · published

Signing and Uploading With Nobody at the Keyboard

~16 min · testflight, signing, app-store-connect-api, keychain, automation

Level 0Bundle Opener
0 XP0/81 lessons0/17 achievements
0/100 XP to next level100 XP to go0% complete
"Not expiry. I think it happens when the screen locks."

What an Unattended Build Leans On

A pipeline that archives and uploads while its owner is away depends on three things it cannot see from its own log. It signs with a private key in the login keychain, which an ssh session cannot use (the previous part's one-shot GUI-domain job is the bridge). It lets xcodebuild -allowProvisioningUpdates talk to Apple's developer services to create and update profiles, which xcodebuild -help says needs either an account added in Xcode's Accounts settings or an App Store Connect API key passed as -authenticationKeyPath, -authenticationKeyID and -authenticationKeyIssuerID. And for an upload, it uses that same authentication to reach App Store Connect.

The Error That Looked Like an Expired Session

An upload died at the very end, after tests, archive and gates had all passed, with a bare error: exportArchive Failed to Use Accounts. It reads exactly like an expired Apple ID session, so the first diagnosis was to sign out of Xcode and back in. That seemed to work, and three uploads succeeded in a row. The next failure arrived precisely when the owner left and switched the display off, and he made the call: not expiry, a locked screen. With the session locked, the keychain refused Xcode's account session, and each failed run had spent eight minutes getting there.

Two fixes followed. A pipeline without an API key now checks the screen before building: CoreGraphics' CGSessionCopyCurrentDictionary() carries CGSSessionScreenIsLocked while the session is locked and omits it while unlocked, calibrated against a real locked session. And the lasting fix removes the dependency: a team API key from App Store Connect, whose private key (AuthKey_<id>.p8) Apple lets you download once, kept outside every repository with its key id and issuer id beside it. With it, xcodebuild authenticates without the Accounts session at all. Give the key the Admin role: a family app measured that a key with the App Manager role may neither create the cloud-managed distribution certificate nor regenerate the managed profiles that automatic signing asks for.

Refuse a Half-Dropped Key

The key reader distinguishes three states. No config means the Mac uses its Xcode session, as before. A complete config prints six flags. A config that exists but is incomplete (a missing .p8, an issuer id that is not a UUID) is a hard failure, because a half-dropped key would otherwise look exactly like no key and quietly upload through the very session it was meant to replace.

One zsh detail decides whether the flags reach xcodebuild intact. The quoted capture ("${(@f)$(…)}") turns empty output into one empty element, so xcodebuild receives an empty-string argument. The unquoted (${(f)"$(…)"}) gives an empty array for no output and still keeps a path with a space as one word. Both were measured before this lesson used them.

Code

asc-auth.zsh: print the key flags, nothing, or a refusal·bash
#!/bin/zsh
# Prints xcodebuild's App Store Connect API key flags, one per line, or nothing.
# No config is not an error (Xcode's keychain session is used). A half-dropped key IS.
set -euo pipefail
store=${ASC_STORE:-$HOME/.appstoreconnect}
config="$store/spark-asc.json"          # {"keyId": "…", "issuerId": "…"}
[[ -e "$config" ]] || exit 0

key_id=$(plutil -extract keyId raw -o - "$config" 2>/dev/null) || key_id=""
issuer=$(plutil -extract issuerId raw -o - "$config" 2>/dev/null) || issuer=""
[[ "$key_id" =~ '^[A-Za-z0-9]+$' ]] || { print -u2 "HARD_FAIL $config: keyId missing or malformed"; exit 1; }
[[ "$issuer" =~ '^[0-9A-Fa-f]{8}-([0-9A-Fa-f]{4}-){3}[0-9A-Fa-f]{12}$' ]] \
  || { print -u2 "HARD_FAIL $config: issuerId missing or malformed"; exit 1; }
key_file="$store/private_keys/AuthKey_${key_id}.p8"
[[ "$(head -n 1 "$key_file" 2>/dev/null)" == "-----BEGIN PRIVATE KEY-----" ]] \
  || { print -u2 "HARD_FAIL $key_file missing or not a PEM private key"; exit 1; }

print -r -- -authenticationKeyPath; print -r -- "$key_file"
print -r -- -authenticationKeyID;   print -r -- "$key_id"
print -r -- -authenticationKeyIssuerID; print -r -- "$issuer"
Use the flags, and refuse a locked screen when there is no key·bash
#!/bin/zsh
set -euo pipefail
asc_auth=(${(f)"$(./asc-auth.zsh)"})     # unquoted (f): no output is an EMPTY array
if (( ${#asc_auth} == 0 )); then
  # CGSSessionScreenIsLocked is present (1) while locked and ABSENT while unlocked.
  screen=$(xcrun swift -e 'import Foundation
import CoreGraphics
let session = CGSessionCopyCurrentDictionary() as? [String: Any] ?? [:]
print((session["CGSSessionScreenIsLocked"] as? NSNumber)?.boolValue == true ? "locked" : "unlocked")' 2>/dev/null | tail -1)
  [[ "$screen" != locked ]] \
    || { print -u2 "HARD_FAIL screen locked and no API key: export would die with 'Failed to Use Accounts'"; exit 1; }
fi
print "key flags: ${#asc_auth}"
print -r -- "xcodebuild … archive -allowProvisioningUpdates ${asc_auth[*]}"

External links

Exercise

Write asc-auth.zsh and prove its three states with a scratch store directory passed through ASC_STORE: no config, a config naming a key file that does not exist, and a complete config with a placeholder PEM file. Then show the capture trap for yourself: capture the empty case both ways and print the element count. Finally, run the lock probe, lock your screen with a delayed command, and record what it prints while locked.
Hint
(sleep 5 && xcrun swift -e '…' > /tmp/lockstate.txt) & followed by locking the screen gives you a reading taken while locked. The placeholder key only has to pass the header check; nothing in this exercise contacts Apple.

Progress

Progress is local-only — sign in to sync across devices.
Spotted a bug or have feedback on this page?Report an Issue

Comments 0

🔔 Reply notifications (sign in)
Sign inPlease sign in to comment.

No comments yet — be the first.