Workflow IA

App Store Connect : soumettre une app iOS depuis le terminal

Chaque soumission, le même temps perdu à cliquer et à téléverser des captures. Cette fois : un script bash, l’API App Store Connect et Chrome piloté par Claude Code. Un seul clic humain.

Objectif : soumettre Parlotte à l’App Store sans ouvrir App Store Connect à la main. Un script bash de 34 lignes, l’API d’Apple, xcodebuild, et Chrome piloté par Claude Code pour le seul écran qu’Apple n’expose pas en API. Un clic humain au total : la signature du contrat.

À chaque app, le même rituel. Retrouver le bon onglet dans App Store Connect. Remplir les mêmes champs que la dernière fois. Téléverser les captures une par une, dans le bon format, dans le bon ordre. Questionnaire de confidentialité, classification par âge, prix, pays. Ce n’est pas du développement, c’est de la manutention. Cette fois, j’ai voulu que ça ne me coûte plus rien.

Le problème : la soumission n’est pas du code

Parlotte est une app iOS qui mesure les temps de parole autour d’une table. Le code était prêt, testé par des amis sur TestFlight. Restait la partie que personne n’aime : la fiche App Store. Elle exige une bonne douzaine de réglages dispersés dans autant d’écrans, et l’interface web ne dit jamais ce qui manque avant que vous tentiez de soumettre.

Or Apple expose presque tout cela dans l’App Store Connect API. Presque : le questionnaire de confidentialité, lui, n’existe qu’en interface web. La méthode a donc deux étages. L’API pour tout ce qu’elle couvre. Un navigateur piloté par programme pour le reste.

Le principe : une clé API plutôt qu’un mot de passe

L’API s’authentifie avec une clé créée dans App Store Connect : un fichier .p8, un identifiant de clé, un identifiant d’émetteur. Ni mot de passe, ni code à six chiffres, ni session qui expire au mauvais moment. On signe un JWT en ES256, valable vingt minutes, et on le passe en en-tête. Tout le reste est du curl.

Le script complet tient en 34 lignes de bash. L’essentiel, c’est la signature : openssl produit une signature au format DER, alors que le JWT attend les deux entiers R et S concaténés en brut. Quatre lignes de conversion, et c’est réglé.

b64() { openssl base64 -e -A | tr '+/' '-_' | tr -d '='; }
NOW=$(date +%s); EXP=$((NOW+1200))
H=$(printf '{"alg":"ES256","kid":"%s","typ":"JWT"}' "$KEY_ID" | b64)
P=$(printf '{"iss":"%s","iat":%s,"exp":%s,"aud":"appstoreconnect-v1"}' "$ISSUER" "$NOW" "$EXP" | b64)
# DER -> R||S brut (64 octets), sinon Apple refuse le jeton
SIG=$(printf '%s.%s' "$H" "$P" | openssl dgst -sha256 -sign "$P8" | \
  openssl asn1parse -inform DER | grep INTEGER | sed 's/.*://' | \
  while read -r hx; do printf '%064s' "$hx" | tr ' ' '0'; done | xxd -r -p | b64)
JWT="$H.$P.$SIG"

curl -sg -X "$METHODE" -H "Authorization: Bearer $JWT" \
  -H "Content-Type: application/json" -d "$CORPS" \
  "https://api.appstoreconnect.apple.com/v1/$CHEMIN"

Ensuite, chaque opération est une ligne : ./asc.sh GET "apps/6801887775/appStoreVersions", ./asc.sh PATCH "appStoreVersions/…" '{…}'. Lisible, rejouable, versionnable. La clé .p8, elle, reste hors du dépôt : c’est un secret qui donne accès à toutes les apps du compte.

Premier piège, bête et silencieux : curl interprète les crochets de fields[apps]=name comme des plages à développer. La commande sort vide, sans erreur. Le flag -g désactive ce globbing. Une lettre, et une réponse vide.

Le build : xcodebuild et la même clé

Pas d’Xcode ouvert non plus. L’archive se fait en ligne de commande, l’export aussi, avec destination=upload dans le plist d’export pour que le binaire parte directement chez Apple. Le numéro de build s’incrémente dans le projet avant chaque envoi : Apple refuse deux fois le même.

xcodebuild archive -project Parlotte.xcodeproj -scheme Parlotte \
  -configuration Release -destination 'generic/platform=iOS' \
  -archivePath /tmp/Parlotte.xcarchive -allowProvisioningUpdates

xcodebuild -exportArchive -archivePath /tmp/Parlotte.xcarchive \
  -exportOptionsPlist exportOptions.plist -allowProvisioningUpdates \
  -authenticationKeyPath /chemin/vers/AuthKey_XXXXXXXXXX.p8 \
  -authenticationKeyID XXXXXXXXXX -authenticationKeyIssuerID <issuer-id>

Les trois derniers flags ne sont pas décoratifs. Sans eux, l’upload dépend de la session Apple ID mémorisée par Xcode. La mienne avait expiré, et l’export a échoué sur un laconique Failed to Use Accounts. Avec la clé API, même celle du script bash, l’upload est passé du premier coup. Deux minutes plus tard, le build était traité et attachable.

La fiche, champ par champ

Voici ce qu’il a fallu poser pour qu’Apple accepte la soumission, et où chaque réglage vit dans l’API. Aucun de ces éléments n’était rempli au départ, et l’interface web ne les signale qu’au moment où vous cliquez sur « Soumettre ».

RéglageRessource APIPiège rencontré
Copyright, type de sortieappStoreVersionsSortie manuelle, pour mettre le site à jour avant que l’app soit visible
Droits sur le contenuappsDes modèles sous licence libre ne sont pas du « contenu tiers » au sens d’Apple
Classification par âgeageRatingDeclarations13 énumérations et 9 booléens ; la ressource n’accepte que PATCH, un GET renvoie 403 ; relecture via appInfos
Contact et notes de reviewappStoreReviewDetailsDistinct de la fiche TestFlight (betaAppReviewDetail) : remplir l’une ne remplit pas l’autre
Prix gratuitappPriceSchedulesCréation inline : l’identifiant local doit garder ses accolades, ${prix}, sinon 409
Disponibilité, 175 paysv2/appAvailabilitiesLa réponse renvoie une liste vide : vérifier par un second GET sur la relation
URL de politique de confidentialitéappInfoLocalizationsPas au même endroit que les URL marketing et support : facile à oublier
Build attaché à la versionappStoreVersions/…/relationships/buildAucun
SoumissionreviewSubmissionsTrois appels : créer, ajouter la version, passer submitted à vrai

Chaque écriture est suivie d’une relecture par GET. Pas par méfiance envers l’API, par méfiance envers moi : une valeur acceptée par le serveur n’est pas forcément celle qu’on croyait avoir envoyée.

Les captures d’écran : un mode démo dans l’app

À la main, les captures sont la partie la plus pénible. Il faut ouvrir le simulateur, naviguer dans l’app jusqu’au bon écran, inventer des données crédibles pour qu’il ne soit pas vide, régler l’heure, prendre la capture, et recommencer pour chaque écran. Puis tout refaire à la version suivante, parce qu’un bouton a bougé. Cette fois, personne n’a touché au simulateur. Trois idées, et le tout tient en quelques lignes.

Comment les captures se fabriquent toutes seules

1. L’app sait se mettre en scène. Au démarrage, elle regarde les arguments avec lesquels on l’a lancée. Si elle voit -demo, elle insère une conversation fictive dans sa propre base : cinq prénoms, des temps de parole, des interruptions. Avec -demo-ouvre, elle s’ouvre directement sur l’écran demandé : les statistiques, l’étiquetage des voix, le consentement. Ces arguments n’existent que depuis un terminal de développeur : en usage normal, le mode démo est inatteignable.

2. Le simulateur obéit au terminal. Le simulateur iOS est un iPhone qui tourne sur le Mac, et tout ce qu’on fait au doigt se fait aussi par commande : l’allumer, figer l’heure à 9:41 comme sur toutes les captures d’Apple, passer en mode clair, installer l’app, la lancer avec les bons arguments.

3. La capture est une commande aussi. Une fois l’écran affiché, une ligne écrit l’image sur le disque, à la résolution native de l’appareil simulé. Un simulateur 6,9 pouces produit du 1320 x 2868 : exactement ce qu’Apple exige pour cette taille, sans retouche.

Concrètement, la séance photo ressemble à ceci. Une préparation, puis deux lignes par écran : lancer l’app sur l’écran voulu, attendre que l’animation se pose, capturer.

xcrun simctl status_bar booted override --time 9:41
xcrun simctl ui booted appearance light
xcrun simctl install booted /tmp/parlotte-dd/Build/Products/Debug-iphonesimulator/Parlotte.app

L="-AppleLanguages (fr) -AppleLocale fr_FR"
xcrun simctl launch --terminate-running-process booted eu.elastik.parlotte -demo -demo-ouvre termine $L
sleep 4 && xcrun simctl io booted screenshot 1-stats.png
xcrun simctl launch --terminate-running-process booted eu.elastik.parlotte -demo -demo-ouvre brouillon $L
sleep 3 && xcrun simctl io booted screenshot 4-consentement.png

Cinq écrans, cinq répétitions, moins d’une minute. Quand un écran change, on relance la séquence au lieu de refaire la séance photo. Et comme la commande est la même à chaque version, les captures de la 1.1 seront cadrées exactement comme celles de la 1.0.

Reste à les déposer chez Apple, par l’API, en trois temps par image. On annonce d’abord le fichier, son nom et sa taille : Apple répond avec une adresse de dépôt. On y envoie les octets. On confirme enfin avec la somme de contrôle du fichier, pour qu’Apple vérifie que rien ne s’est abîmé en route. L’ordre des captures est un choix, pas un hasard : l’écran de consentement passe en premier. Pour une app qui enregistre, c’est le point qu’Apple regarde de près.

Là où Apple n’a pas d’API : le navigateur, piloté

Le questionnaire de confidentialité n’a pas d’API publique. C’est le seul écran qui a exigé un navigateur, et je ne l’ai pas cliqué moi-même. Chrome Canary tournait avec son port de débogage ouvert, et Claude Code l’a piloté par le Chrome DevTools Protocol : 61 lignes de Node pour naviguer, exécuter du JavaScript dans la page et prendre des captures.

La technique est volontairement rustique. Pas de coordonnées de clic, qui cassent au premier changement de mise en page. On cherche un bouton par son texte et on le clique depuis la page elle-même :

[...document.querySelectorAll('button')]
  .find(b => /Get Started/i.test(b.textContent))
  .click()

Puis une capture d’écran, relue avant l’étape suivante. « Non, nous ne collectons pas de données », enregistrer, publier, confirmer. Quatre clics, quatre captures. Et au passage, un détail qui manquait depuis le début : l’URL de la politique de confidentialité était vide dans l’interface. Posée par API dans la minute.

C’est aussi le navigateur qui a révélé le vrai bloqueur. Sur la page des accords, une bannière : le contrat de licence Apple Developer avait été mis à jour, et sans acceptation par le titulaire du compte, aucune nouvelle soumission ne passe. L’API ne l’aurait dit qu’au dernier appel, par un refus.

Ce que j’ai gardé pour moi

Ce contrat, je l’ai accepté moi-même. Claude Code a ouvert la page et s’est arrêté devant le bouton : un engagement juridique, ce n’est pas un clic qu’on délègue. Même logique pour la politique de confidentialité publiée sur le site, qui m’engage comme responsable de traitement, et pour le choix de la sortie manuelle plutôt qu’automatique.

Le reste a tourné en parallèle : un agent sur le code et le build, un sur l’API, un sur le site, puis deux relecteurs chargés de contredire les trois premiers. L’un d’eux a trouvé un « vos » dans une app qui tutoie partout. Rebuild, numéro suivant, nouvel upload. Cinq agents, 98 appels d’outils, huit minutes et demie.

Résultats

MesureValeur
Du lancement des agents à « En attente de review »29 minutes, un rebuild compris
Traitement du build par Apple2 minutes
Écrans App Store Connect cliqués à la main1 : le contrat
Coût0 euro au-delà du compte développeur

Les limites

  • Le questionnaire de confidentialitĂ© reste hors API - le pilotage du navigateur fonctionne, mais un changement d’interface chez Apple peut le casser. On le saura Ă  la capture suivante, pas avant.
  • La clĂ© API est puissante - elle ouvre toutes les apps du compte. Hors dĂ©pĂ´t, jamais dans un message, jamais dans une capture.
  • Les erreurs sont parfois muettes - une rĂ©ponse vide, une liste vide dans un POST rĂ©ussi. Sans relecture systĂ©matique, on croit avoir fini.

Le bilan

  • La soumission est un problème d’état, pas de clics. Une douzaine de champs Ă  poser, dans n’importe quel ordre, vĂ©rifiables un par un. C’est exactement ce qu’une API fait mieux qu’un humain.
  • Automatiser jusqu’à la frontière, pas au-delĂ . Un contrat se signe Ă  la main. Le savoir avant de commencer Ă©vite de le dĂ©couvrir en production.
  • Les pièges sont petits et rĂ©currents. Un flag -g, des accolades dans un identifiant, une session expirĂ©e. ConsignĂ©s, ils ne coĂ»tent qu’une fois.
  • Ce n’est pas magique. C’est un script, une clĂ©, et la discipline de vĂ©rifier. La prochaine app repartira de lĂ .

Les ressources

La documentation de l’API d’Apple, le protocole qui pilote Chrome, et l’app qui a servi de cobaye : Parlotte mesure qui parle autour de la table, sans comprendre un mot, sans que rien ne quitte le téléphone.

App Store Connect API · Chrome DevTools Protocol · Parlotte