Jacques Foucry bio photo

Jacques Foucry

IT, râleur et faiseur de pain

Email



Le troisième article sur la compilation automatique iOS et la distribution OTA

Résumé des épisodes précédents

Dans notre premier épisode nous avons vu comment installer Java, Jenkins, et compiler, sans trop de difficulté une application OSX. Dans le deuxième épisode, nous avons joué avec la keychain et nous nous sommes rendu compte que même avec un script nous avions des problèmes.

À qui la faute ?

Après avoir longtemps cherché, la faute en est à la keychain. Pour faire une compilation destinée à un iDevice, le code doit être signé avec le certificat développeur. Dans la keychain on trouve l’identité du développeur. Même, ne déveouillant la keychain contenant cette identité et en la rendant keychain par défaut, le flux Jenkins s’obstine à chercher les éléments dans la keychain System. La solution, bien que sale, semble de mettre les éléments dans cette Keychain. Mais non, là encore, l’identité n’est pas trouvée.

Alors, qu’est-ce qu’on fait ?

On pourrait laisser tomber… ou s’obstiner… ou partir sur une autre idée. J’ai choisi la troisième option et ait décidé de faire un script python qui va tout faire pour moi !

Pourquoi Python ?

Parce que… J’ai envie d’apprendre Python depuis longtemps et j’ai l’occasion de m’y mettre avec ce script. Python dispose d’une foultitude de modules dont certain qui peuvent nous simplifier la vie dans ce cas.

Quelle version ?

Bien que la version 3.x de Python soit disponible depuis un petit bout de temps maintenant, nous allons rester sur la dernière des versions 2.x, la 2.7.x. En effet, de nombreux modules n’ont pas encore été portés pour Python3.

Synopsis de notre script

Dans les grandes lignes notre script doit appeler xcodebuild pour compiler notre projet, appeler xcrun pour transformer le .app issue de la compilation en .ipa, créer un fichier manifest.plist utilisé pour la distribution OTA et envoyer le tout vers la machine de distribution.

Nous verrons bien vite qu’il va falloir ajouter des fonctionnalités pour permettre à ces quatre étapes d’opérer correctement.

Découper le script en fonction

Nous allons découper notre script en fonction. C’est plus facile à tester, plus facile à comprendre et moins long à écrire.

Je ne suis pas un expert de Python, il est fort possible que mon script ne soit pas parfait et aurait pu être plus efficacement. Toutefois il permet d’arriver au but fixé et c’est déjà pas mal

xcodebuild, la ligne de commande

Commençons par la ligne de commande qui permet la compilation de notre projet.

1
$ xcodebuild -project MonPrpject.xcodeproj -target maTarget -sdk iphoneos5.0 --configuration Release

Si vous renseignez correctement les variables et que votre projet compile sur votre machine de développement, il est fort probable que cette compilation fonctionne.

Intégrons cela dans une fonction :

1
2
3
4
5
6
7
8
def compileApp(SDK, project, configuration, target):
  cmd_array=(["/usr/bin/xcodebuild","-sdk",SDK,"-project ",project,"-configuration",configuration, "-target", target])
  try:
    subprocess.check_call(cmd_array)
    except OSError as e:
    logger.debug("Error in %s. Exiting"% " ".join(cmd_array))
    writeToSTDERR("Error, please see the log file")
    sys.exit(1)

La plupart des fonctions font être faites sur ce modèle. La ligne de commande est créée dans une variable, exécutée par subprocess.check_call avec une traque des exceptions

Cette fonction ne marche pas telle quelle. En effet, elle utilise subprocess.check_call qui fait partie du module subprocess, d’un logger pour mettre des traces dans un fichier externe. Nous allons aussi avoir besoin de récupérer, de la ligne de commande qui lance notre script, des paramètres.

Écriture du script

1
2
#!/usr/bin/python2.7
# -*-coding:utf-8 -*-

Tout d’abord le chemin vers l’interpréteur de notre script, ici python 2.7. Ajoutons une ligne qui indique le codage utilisé pour le script.

1
2
3
4
5
import logging
import os
import sys
import subprocess
import argparse

Import des modules dont nous aurons besoin. os et sys sont importants ils permettent de dialoguer avec l’OS. Les deux autres logging et subprocess sont ceux dont nous avons besoin dans nos fonctions. Enfin, argparse est celui qui va nous permettre de récupérer les arguments de la ligne de commande.

Au cours de notre développement, nous aurons besoin d’autres modules, il faudra ajouter ici la ligne d’importation nécessaire.

Récupération des paramètres

Pour récupérer les arguments passés en paramètres, nous avons besoin d’un objet de type ArgumentParser. Nous allons ensuite ajouter à cet objet les arguments que nous acceptons, avec un court message d’aide et le nom de la variable qui accueillera la valeur transmise.
Enfin, nous allons copier les valeurs dans des variables plus facilement manipulables. Nous allons également ajouter un paramètre pour régler le niveau de trace que nous voulons dans notre fichier de trace.

Les paramètres sont stockés dans un tableau (parser.parse-args). Nous copions ce tableau et de cette copie nous extrayons les valeurs.

1
2
3
4
5
6
7
8
9
10
11
12
13
# --- Parse arguments with argparse

parser = argparse.ArgumentParser(description=xcodebuild wrapper parameters') parser.add_argument('-P', '--projectPath', action="store", required=True, dest="project", help="Path to the project file")
parser.add_argument('-s', '--sdk', action="store", required=True, dest="sdk", help="SDK")
parser.add_argument('-c', '--configuration', action="store", required=True, dest="config", help="Configuration")
parser.add_argument('-t', '--target', action="store", required=True, dest="target", help="Path to projet's file")

parser.add_argument('--log', action="store", dest="logLevel", help="LogLevel, could be DEBUG | INFO | WARNING | ERROR | CRITICAL. Default value is INFO", default="INFO")

args = parser.parse-args()

project = args.project
SDK = args.sdk configuration = args.configuration target = args.target logLevel = args.logLevel

Avant de continuer, mettons en place le logger.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
# --- Logger Initialization
logger = logging.getLogger('xcodebuild-wrapper')
logHandler = logging.FileHandler('/tmp/xcodebuild-wrapper.log')
formatter = logging.Formatter('%(asctime)s %(levelname)s %(message)s')
logHandler.setFormatter(formatter)
logger.addHandler(logHandler)

# --- Define LogLevel

if logLevel=="DEBUG":
  logger.setLevel(logging.DEBUG)
elif logLevel=="INFO":
  logger.setLevel(logging.INFO)
elif logLevel=="WARNING":
  logger.setLevel(logging.WARNING)
elif logLevel=="ERROR":
  logger.setLevel(logging.ERROR)
elif logLevel=="CRITICAL":
  logger.setLevel(logging.CRITICAL)
else: logger.info('Unkown loglevel '+ debugLevel +', using INFO')

Voilà, nous avons tous les éléments nécessaires pour notre première fonction. Celle-ci doit être définie AVANT son appel.
Pour l’appeler, il suffit de mettre son nom avec entre parenthèses les paramètres qu’elle attend.

1
compileApp(SDK, project, configuration,target)

Transformer l’application .app en .ipa

La fonction compileAPP prend votre projet et tous les éléments dont il a besoin pour en faire un bundle .app. Or, sur un iDevice ce ne sont pas des app qui tournent, mais des .ipa (qui sont en fait des fichiers zippés). Il faut donc transformer notre .app en .ipa. cela se fait avec xcrun. xcrun ne fait rien en lui même. Il appelle un autre outil de développement PackageApplication.

Voici la fonction createIPA qui réalise cette transformation:

1
2
3
4
5
6
7
8
9
10
def createIPA(GSDK, workspace, configuration, target, DeveloperName, ProvisioningProfile,targetFolder,APPPath):
  cmd_array=(["/usr/bin/xcrun","-sdk",GSDK, "PackageApplication", "-v"," %s/%s.app"% (APPPath,target),"-o","%s/%s.ipa"%(targetFolder,target),"-sign",""%s""%(DeveloperName), "-embed",""%s""%(ProvisioningProfile)])

  try:
    subprocess.check_call(cmd_array)
    except OSError as e:
    logger.debug("Error in %s. Exiting"% " ".join(cmd_array))
    logger.info(" Error String == %s Error Num == %d"% (e.strerror, e.errno))
    writeToSTDERR("Error, please see the log file")
    sys.exit(1)

Le principe est le même que précédemment, la ligne de commande est décomposée et mise dans un tableau. Tout de suite nous voyons que nous avons besoin d’autres paramètres. Il va donc falloir ajouter des lignes à la réception des paramètres.

Les paramètres qui manquent sont :

  • GSDK
  • workspace
  • DeveloperName
  • ProvisioningProfile
  • APPPath
  • targetFolder

Définition des paramètres

Certains de ces paramètres (GDSK, APPPath et workspace) sont issus de variables déjà connues. GSDk est SDK sans le numéro de releases (iphoneos5.0 devient iphoneos), workspace est le répertoire qui contient le projet, APPPath est le répertoire dans lequel on trouve le .app résultat de la compilation. Plaçons la définition de ces variables avant l’appel au compileAPP.

1
2
3
workspace = os.path.dirname(project)
GSDK = SDK[:-3]
APPPath="%s/build/%s-%s"%(workspace,configuration,GSDK)

targetFolder est le répertoire dans lequel nous allons placer le fichier .ipa (et d’autres choses). Ce répertoire doit être différent pour chaque compilation. Afin qu’il soit unique nous allons le nommer du nom de la target plus le timestamp courant. Une fois son nom défini, nous allons le créer à l’aide d’une fonction. La définition est à placer juste après celles de nos autres variables, la fonction doit être avant compileAPP et l’appel doit lui aussi se trouver avant l’appel à compileAPP.

La définition:

1
2
3
timestamp = int(time.time())
targetFolder="/tmp/%s-%d"%(target,timestamp)
logger.debug("targetFolder == %s"%(targetFolder))

La fonction :

1
2
3
4
5
6
7
8
9
10
11
12
def createTargetFolder(folder):
try:
  subprocess.call(["/bin/mkdir", folder])
  except OSError as e:
  if os.path.exists(folder):
  logger.info("%s folder already exist, Exiting")
  logger.debug("ErrorString == %s ErrorNum == %d"% (e.strerror,e.errno))
  pass
  else:
  raise Exception("Error in creating %s folder"% (folder))
  writeToSTDERR("Error, please see the log file")
  sys.exit(1)

time est déclaré dans un module, il va falloir l’ajouter dans les imports.

1
  import time

Ajout dans la gestion des paramètres

Pour ce qui est de la récupération des paramètres, il faut ajouter le ProvisioningProfile et le DeveloperName. D’abord sous forme de add_argument :

1
2
parser.add_argument('-n', '--developerName', action="store", required=True, dest="devname", help="Developer name. This information is in the provisionign profile")
parser.add_argument('-P', '--provisioningProfile', action="store", required=True, dest="ProvisioningProfile", help="Provisioning Profile path")

Puis sous forme de récupération de valeur :

1
2
DeveloperName = args.devname
ProvisioningProfile = args.ProvisioningProfile

Et maintenant ?

Maintenant, cela a peu de chance de fonctionner. Il manque ce pour quoi nous nous sommes lancés dans cette aventure, la gestion de la keychain.
Nous avons besoin de :

  • la keychain
  • son mot de passe

Deviner quoi ? Nous allons ajouter ces deux éléments à la liste de ceux qui sont attendus dans la ligne de commande.

Le mot de passe est en clair, il peut être intercepté par une commande ps au bon moment

Gestion des paramètres

1
2
3
parser.add_argument('-k', '--keychain', action="store", required=True, dest="keychain", help="Path to the keychain file")
parser.add_argument('-K', '--keychainPassword', action="store", required=True, dest="keychainPassword", help="keychain's password")
keychain = args.keychain password = args.keychainPassword

Et la fonction qui ouvre et passe la keychain en keychain par défaut.

1
2
3
4
5
6
7
8
9
10
11
12
def openKeychain(password, keychain):
  cmd_array=(["/usr/bin/security","unlock-keychain", "-p", password, keychain])
  try: subprocess.check_call(cmd_array)
  except OSError as e:
    logger.debug("Error in %s, exiting"% " ".join(cmd_string))
    writeToSTDERR("Error, please see the log file")
    sys.exit(1)

  cmd_array=(["/usr/bin/security", "default-keychain","-d","user", "-s", keychain])
  try: subprocess.check_call(cmd_array)
    except OSError as e:
    logger.debug("Error in %s, exiting"% " ".join(cmd_array))

Cette définition est à mettre avant les appels aux fonctions et l’appel lui-même avant l’appel à compileAPP.

La suite, la suite, la suite

Avant de la passer à la suite… faisons un bilan, histoire de voir les plus lents recoller au peloton.
Notre script se compose ainsi :

  • Import des modules
  • gestion des paramètres de la ligne de commande
  • définition du logger
  • fonction d’ouverture de la keychain
  • fonction de création du dossier de destination
  • fonction de compilation .app
  • fonction de transformation .app en .ipa
  • définition de certaines variables
  • appel de la fonction de création du dossier de destination
  • appel de la fonction d’ouverture de la keycahin
  • appel de la fonction de compilation .app
  • appel de la fonction de transformation .app en .ipa

Que manque t’il ?

Pour être distribué notre .ipa a besoin de :

  • un fichier manifest.plist qui décrit la méthode de distribution et ses paramètres
  • un fichier html qui contient le lien de téléchargement

Commençons par le fichier manifest.plist

Celui-ci peut être généré depuis notre script python en prenant les paramètres dans notre .ipa grâce au module plistlib. Le fichier .ipa étant un fichier zip, il est nécessaire d’avoir aussi le module zipfile. Enfin, pour réaliser l’extraction nous utilisons le module shtuil. Trois lignes de plus dans notre section import.

1
2
3
import zipfile
import plistlib
import shutil

Pour créer ce fichier, deux fonctions sont nécessaires. La première va extraire du fichier .ipa le fichier Info.plist (une plist binaire). La deuxième va transformer cette plist binaire en un plist texte et en extraire les informations dont nous avons besoin.

Extraction du fichier Info.plist

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
def retrieveInfo(targetFolder,target):
  zin=zipfile.ZipFile("/%s/%s.ipa"%(targetFolder,target)) # definiton du fichier zip
  logger.debug(zin)

  for item in zin.namelist(): # parcours des fichiers contenus dans le zip
  if fnmatch.fnmatch(item, '*/Info.plist'): # recherche du fichier Info.plist
  filename = os.path.basename(item)
  filename = "/%s/%s"%(targetFolder,filename)
  inFile = zin.open(item)
  outFile = file(filename, "wb")

  shutil.copyfileobj(inFile,outFile) # extraction du fichier
  inFile.close()
  outFile.close()
  return filename

Création du fichier manifest.plist avec les éléments extraits d’Info.plist

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
def createManifest(info,target,targetFolder,deployment_address):
  xmlfile="/%s/%s.xml"%(targetFolder,target)

  # we need to convert Info.plist into a xml file (by default it's a binary plist)
  cmd_array=(["/usr/bin/plutil", "-convert", "xml1", "-o", xmlfile, info])
  subprocess.Popen(cmd_array).wait()
  infoPlistFile = open(xmlfile, 'r')
  app_plist = plistlib.readPlist(infoPlistFile)
  os.remove(xmlfile)
  manifestFilename='/%s/manifest.plist'%(targetFolder)
  manifest_plist= {
    'items' : [ {
      'assets' : [
      {
        'kind' : 'software-package',
        'url' : urlparse.urljoin(deployment_address, target + '.ipa'),
      }
      ],
      'metadata' : {
        'bundle-identifier' : app_plist['CFBundleIdentifier'],
        'bundle-version' : app_plist['CFBundleVersion'],
        'kind' : 'software',
        'title' : app_plist['CFBundleName'],
    }
    }
    ]
  }
  plistlib.writePlist(manifest_plist, manifestFilename)
  return manifestFilename

Le fichier html

Dans le fichier html nous devons indiquer l’url de distribution de notre application. Il va falloir ajouter ce paramètre à la liste de ceux que nous lisons déjà de la ligne de commande.

1
2
3
parser.add_argument('-d', '--deploymentAddress', action="store", required=True, dest="deploy", help="Deployment Address, used in manifest.plist file")
  ...
deployment_address = args.deploy

Et créer une fonction pour générer le fichier html (index.html sera sont nom). Nous allons faire un modèle et remplir certains éléments avec le contenu de variables. Une autre fonction appelle celle de création du fichier html avec les bons paramètres.

1
2
3
4
5
6
7
def distribution(server, user, password,distantFolder,sourceFolder):
  cmd_array=(["/usr/bin/scp", "-r", "%s/*"%(targetFolder), "%s@%s:%s"%(user,server,distantFolder)])
  try:
    subprocess.check_call(cmd_array)
    except OSError as e:
    logger.debug("Error in %s. Exiting"% " ".join(cmd_array))
    logger.debug("ErrorString == %s ErrorNum == %d"% (e.strerror,e.errno))

Pour finir ce long billet

Reprenons l’architecture de notre script et voyons ce que nous y avons ajouté :

  • Import des modules
  • gestion des paramètres de la ligne de commande
  • définition du logger
  • fonction d’ouverture de la keychain
  • fonction de création du dossier de destination
  • fonction de compilation .app
  • fonction de transformation .app en .ipa
  • définition de certaines variables
  • appel de la fonction de création du dossier de destination
  • appel de la fonction d’ouverture de la keychain
  • appel de la fonction de compilation .app
  • appel de la fonction de transformation .app en .ipa
  • création du manifest.plist
  • Extraction du fichier Info.plist du .ipa
  • création du fichier index.html
  • copie des fichiers sur la machine cible

Notre script est désormais terminé. Dans un prochain billet, nous verrons comment y introduire deux ou trois fonctionnalités supplémentaires.

Licence Creative Commons
xcodebuild-wrapper.py de Jacques Foucry est mis à disposition selon les termes de la licence Creative Commons Paternité – Partage à l’Identique 3.0 non transposé.

Vous pouvez également retrouver ce script sur GitHub.


Billet Précédent Billet Suivant

Laisser un commentaire

Les commentaires sont soumis à modération.