Automated ad hoc builds using Xcode 4

Jonah Williams ·

I’ve previously discussed Continuous Integration for iPhone Projects in TeamCity using Xcode 3 and Building Xcode 4 Projects from the Command Line. Now I’ll tie those together and use TeamCity to automatically create ad hoc builds I can install over the air (directly onto a device without using iTunes) every time I check in code.

I created a basic project configuration in TeamCity 6 to checkout my iOS project from git.

I set the artifact paths for this configuration to

project_name.acceptance.app*
ad_hoc/*.png

This will eventually allow TeamCity to collect the icons, project_name.acceptance.app.ipa, and project_name.acceptance.app.plist files needed to perform an over the air install of the ad hoc build.

Finally I created the following custom script build step (available as https://gist.github.com/949831).

#!/bin/bash

# https://gist.github.com/949831
# https://blog.carbonfive.com/2011/05/04/automated-ad-hoc-builds-using-xcode-4/

# command line OTA distribution references and examples
# http://nachbaur.com/blog/how-to-automate-your-iphone-app-builds-with-hudson
# http://nachbaur.com/blog/building-ios-apps-for-over-the-air-adhoc-distribution
# http://blog.octo.com/en/automating-over-the-air-deployment-for-iphone/
# http://www.neat.io/posts/2010/10/27/automated-ota-ios-app-distribution.html

project_dir=`pwd`

# Configuration
environment_name="staging"
keychain="ci_keys"
keychain_password="super secret"
workspace="MyApp.xcworkspace"
scheme="Ad Hoc"
info_plist="$project_dir/MyApp-Info.plist"
environment_plist="$environment_name.plist"
environment_info_plist="$environment_name-Info.plist"
product_name="My App $environment_name"
mobileprovision="$project_dir/ad_hoc/MyAppStaging.mobileprovision"
provisioning_profile="iPhone Distribution: My Company, LLC"
build_number="%env.BUILD_NUMBER%"
artifacts_url="http://my_ci_server.example/artifacts/$build_number"
display_image_name="Icon-57.png"
full_size_image_name="Icon-512.png"

function failed()
{
    local error=${1:-Undefined error}
    echo "Failed: $error" >&2
    exit 1
}

function validate_keychain()
{
  # unlock the keychain containing the provisioning profile's private key and set it as the default keychain
  security unlock-keychain -p "$keychain_password" "$HOME/Library/Keychains/$keychain.keychain"
  security default-keychain -s "$HOME/Library/Keychains/$keychain.keychain"
  
  #describe the available provisioning profiles
  echo "Available provisioning profiles"
  security find-identity -p codesigning -v

  #verify that the requested provisioning profile can be found
  (security find-certificate -a -c "$provisioning_profile" -Z | grep ^SHA-1) || failed provisioning_profile  
}

function describe_sdks()
{
  #list the installed sdks
  echo "Available SDKs"
  xcodebuild -showsdks  
}

function describe_workspace()
{
  #describe the project workspace
  echo "Available schemes"
  xcodebuild -list -workspace $workspace
}

function increment_version()
{
  cd "MyApp"
  agvtool -noscm new-version -all $build_number
  cd ..
}

function set_environment()
{
  #copy the info plist for the selected environment into place
  cp -v "MyApp/$environment_info_plist" $info_plist || failed environment_plist
  #copy the environment settings plist into place
  cp -v "MyApp/$environment_plist" "MyApp/environment.plist" || failed environment

  #extract settings from the Info.plist file
  info_plist_domain=$(ls $info_plist | sed -e 's/\.plist//')
  short_version_string=$(defaults read "$info_plist_domain" CFBundleShortVersionString)
  bundle_identifier=$(defaults read "$info_plist_domain" CFBundleIdentifier)
  echo "Environment set to $bundle_identifier at version $short_version_string"
}

function build_app()
{
  local devired_data_path="$HOME/Library/Developer/Xcode/DerivedData"

  #get the name of the workspace to be build, used as the prefix of the DerivedData directory for this build
  local workspace_name=$(echo "$workspace" | sed -n 's/\([^\.]\{1,\}\)\.xcworkspace/\1/p')
  #build the app
  echo "Running xcodebuild > xcodebuild_output ..."

#  disabled overriding PRODUCT_NAME, setting applies to all built targets in Xcode 4 which renames static library target dependencies and breaks linking
#  xcodebuild -verbose -workspace "$workspace" -scheme "$scheme" -sdk iphoneos -configuration Release clean build PRODUCT_NAME="$product_name" >| xcodebuild_output
  xcodebuild -verbose -workspace "$workspace" -scheme "$scheme" -sdk iphoneos -configuration Release clean build >| xcodebuild_output

  if [ $? -ne 0 ]
  then
    tail -n20 xcodebuild_output
    failed xcodebuild
  fi
  
  #locate this project's DerivedData directory
  local project_derived_data_directory=$(grep -oE "$workspace_name-([a-zA-Z0-9]+)[/]" xcodebuild_output | sed -n "s/\($workspace_name-[a-z]\{1,\}\)\//\1/p" | head -n1)
  local project_derived_data_path="$devired_data_path/$project_derived_data_directory"
  #locate the .app file

#  infer app name since it cannot currently be set using the product name, see comment above
#  project_app="$product_name.app"
  project_app=$(ls -1 "$project_derived_data_path/Build/Products/Release-iphoneos/" | grep ".*\.app$" | head -n1)
  
  # if [ $(ls -1 "$project_derived_data_path/Build/Products/Release-iphoneos/$project_app" | wc -l) -ne 1 ]
  if [ $(ls -1 "$project_derived_data_path/Build/Products/Release-iphoneos/" | grep ".*\.app$" | wc -l) -ne 1 ]
  then
    echo "Failed to find a single .app build product."
    # echo "Failed to locate $project_derived_data_path/Build/Products/Release-iphoneos/$project_app"
    failed locate_built_product
  fi
  echo "Built $project_app in $project_derived_data_path"

  #copy app and dSYM files to the working directory
  cp -Rf "$project_derived_data_path/Build/Products/Release-iphoneos/$project_app" $project_dir
  cp -Rf "$project_derived_data_path/Build/Products/Release-iphoneos/$project_app.dSYM" $project_dir
  
  #rename app and dSYM so that multiple environments with the same product name are identifiable
  echo "Retrieving build products..."
  rm -rf $project_dir/$bundle_identifier.app
  rm -rf $project_dir/$bundle_identifier.app.dSYM
  mv -f "$project_dir/$project_app" "$project_dir/$bundle_identifier.app"
  echo "$project_dir/$bundle_identifier.app"
  mv -f "$project_dir/$project_app.dSYM" "$project_dir/$bundle_identifier.app.dSYM"
  echo "$project_dir/$bundle_identifier.app.dSYM"
  project_app=$bundle_identifier.app
  
  #relink CodeResources, xcodebuild does not reliably construct the appropriate symlink
  rm "$project_app/CodeResources"
  ln -s "$project_app/_CodeSignature/CodeResources" "$project_app/CodeResources"
}

function sign_app()
{
  echo "Codesign as \"$provisioning_profile\", embedding provisioning profile $mobileprovision"
  #sign build for distribution and package as a .ipa
  xcrun -sdk iphoneos PackageApplication "$project_dir/$project_app" -o "$project_dir/$project_app.ipa" --sign "$provisioning_profile" --embed "$mobileprovision" || failed codesign  
}

function verify_app()
{
  #verify the resulting app
  codesign -d -vvv --file-list - "$project_dir/$project_app" || failed verification  
}

function build_ota_plist()
{
  echo "Generating $project_app.plist"
  cat << EOF > $project_app.plist
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
  <key>items</key>
  <array>
    <dict>
      <key>assets</key>
      <array>
        <dict>
          <key>kind</key>
          <string>software-package</string>
          <key>url</key>
          <string>$artifacts_url/$project_app.ipa</string>
        </dict>
        <dict>
          <key>kind</key>
          <string>full-size-image</string>
          <key>needs-shine</key>
          <true/>
          <key>url</key>
          <string>$artifacts_url/$full_size_image_name</string>
        </dict>
        <dict>
          <key>kind</key>
          <string>display-image</string>
          <key>needs-shine</key>
          <true/>
          <key>url</key>
          <string>$artifacts_url/$display_image_name</string>
        </dict>
      </array>
      <key>metadata</key>
      <dict>
        <key>bundle-identifier</key>
        <string>$bundle_identifier</string>
        <key>bundle-version</key>
        <string>$short_version_string $build_number</string>
        <key>kind</key>
        <string>software</string>
        <key>subtitle</key>
        <string>$environment_name</string>
        <key>title</key>
        <string>$project_app</string>
      </dict>
    </dict>
  </array>
</dict>
</plist>
EOF
}

echo "**** Validate Keychain"
validate_keychain
echo
echo "**** Describe SDKs"
describe_sdks
echo
echo "**** Describe Workspace"
describe_workspace
echo
echo "**** Set Environment"
set_environment
echo
echo "**** Increment Bundle Version"
increment_version
echo
echo "**** Build"
build_app
echo
echo "**** Package Application"
sign_app
echo
echo "**** Verify"
verify_app
echo
echo "**** Prepare OTA Distribution"
build_ota_plist
echo
echo "**** Complete!"

That is quite a few functions but the bottom of the script steps through them in what is hopefully an understandable sequence.

  1. Unlock the keychain containing the private key and provisioning profile used to sign the ad hoc build. Necessary since the TeamCity user’s login keychain may be locked when this build runs.
  2. List the available sdks on the build machine (unnecessary but I found it helpful when debugging build settings).
  3. List the schemes found in the workspace to be built (again purely for debugging).
  4. Copy a plist containing application settings to “environment.plist” which will be copied into the app bundle when built and used to define application behavior, for example it contains the url of the server this build should communicate with.
  5. Build the app using the specified workspace and scheme. Copy the resulting app and dSYM to the project directory so TeamCity can easily find them as build artifacts.
  6. Sign the app using the specified mobile provisioning profile and create a “.ipa” package.
  7. Verify that the app was successfully signed.
  8. Create a plist to allow over the air installation of the app.

Once run any device which has been added to the mobile provisioning profile used to sign this build can install the app just by visiting (using an appropriate %system.teamcity.buildType.id% for the TeamCity build configuration).

itms-services://?action=download-manifest&url=http://teamcity.example.com:8111/guestAuth/repository/download/%system.teamcity.buildType.id%/.lastSuccessful/project_name.acceptance.app.plist

Possible issues:

  • Access to the keychain will present a security dialog the first time this build runs so it was necessary for me to sign into the TeamCity user’s account using VNC and allow access to that keychain.
  • I found that builds occasionally failed to correctly create the CodeResources symlink so I recreate it manually. When this link was broken the ipa would fail verification.
  • An iOS device won’t be able to install the app if the artifacts require authenticating to the TeamCity server so I enabled guest access. Alternately I could have exposed those artifacts through some other service and created a nice installation guide page which links to them if I didn’t want to allow guest access to my TeamCity server.

For additional resources for building over the air distribution builds take a look at:

Mike Nachbaur’s posts on “How to Automate your iPhone App Builds with Hudson” and “Building iOS Apps for Over the Air Ad Hoc Distribution“.
Vincent Daubry’s “Automating Over The Air Deployment for iPhone“.
Basil Shkara’s “Automated OTA iOS App Distribution“.