Skip to content

Instantly share code, notes, and snippets.

@qwzybug
Created September 8, 2011 19:35
Show Gist options
  • Star 4 You must be signed in to star a gist
  • Fork 1 You must be signed in to fork a gist
  • Save qwzybug/1204447 to your computer and use it in GitHub Desktop.
Save qwzybug/1204447 to your computer and use it in GitHub Desktop.
Ruby script to build, tag, archive, and distribute an app and its dSYM file, in one fell swoop.
TF_API_TOKEN: <your TestFlight API token>
TF_TEAM_TOKEN: <your TestFlight team token>
TF_DISTRIBUTION: <name of distribution list to notify>
HOCKEY_APP_ID: <your HockeyApp public App ID>
HOCKEY_APP_TOKEN: <your HockeyApp API token>
DEVELOPER_PREFIX: <path to your developer directory, e.g., /Developer-4.2>
ARCHIVE_DIRECTORY: <path for saving archived builds>
DEVELOPER_NAME: <keychain name of developer certificate>
PROVISONING_PROFILE: <full path to distribution provisioning profile>
BUILD_CONFIGURATION: <OPTIONAL name of the XCode configuration to build, default Release>
WORKSPACE: <OPTIONAL xcworkspace path>
SCHEME: <OPTIONAL scheme name to build, required if you specify WORKSPACE>
#!/usr/bin/env ruby
require 'rubygems'
require 'json'
require 'net/http'
require 'net/http/post/multipart'
require 'net/https'
require 'time'
require 'yaml'
##
## Prepares a build, archives it with its dSYM file, and posts to TestFlight or HockeyApp.
## Run from the same directory as your .xcodeproj file.
## https://gist.github.com/1204447
##
## Expects build_settings.yml, which should look like this:
###
# TF_API_TOKEN: <your TestFlight API token>
# TF_TEAM_TOKEN: <your TestFlight team token>
# TF_DISTRIBUTION: <name of distribution list to notify>
#
## To build for HockeyApp instead of TestFlight, include these lines, and run with `prepare_build hockey`
# [HOCKEY_APP_ID: <your HockeyApp public App ID>]
# [HOCKEY_APP_TOKEN: <your HockeyApp API token>]
#
# DEVELOPER_PREFIX: <path to your developer directory, e.g., /Developer-4.2>
# ARCHIVE_DIRECTORY: <path for saving archived builds and dSYM files>
#
# DEVELOPER_NAME: <keychain name of developer certificate>
# PROVISONING_PROFILE: <full path to distribution provisioning profile>
# [BUILD_CONFIGURATION: <name of the XCode configuration to build, default Release>]
#
# [WORKSPACE: <workspace name, if you build off a workspace instead of an xcodeproj in the current directory>]
# [SCHEME: <scheme name to build from the workspace. required if you specify WORKSPACE>]
###
## Put it in:
## ./build_settings.yml
## ./.build_settings.yml
## ~/.build_settings.yml
## Expects a Version History.txt file in the working directory.
## Starting from the top of the file, will include every consecutive
## line not starting with "Build" as the current release notes.
## E.g.,
###
## - Fix instant crash on every launch
## - Change all buttons to puce
## - Re-greek all copy
##
## Build 001 - 2011-08-08T04:04:04Z
## - Initial beta
### The script will tag the version history file each release.
def fail status, msg
`rm -rf build`
`git checkout -- "Version History.txt"`
`git checkout -- "#{@plist_path}"` if @plist_path
puts "ERROR: #{msg}"
exit status
end
["./build_settings.yml", "./.build_settings.yml", "#{ENV['HOME']}/.build_settings.yml"].each do |path|
if File.exists? path
puts "Using settings from #{path}..."
CONFIG = YAML::load(open(path))
break
end
end
fail -1, "No build_settings.yml found!" unless CONFIG
def check_git_status
clean = `git status` =~ /working directory clean/
unless clean
puts "Can't prepare a build with a dirty working directory! Commit your work and try again."
exit -1
end
end
def project_file_path
xc_proj = Dir["./*.xcodeproj"].first
return nil unless xc_proj
return "#{xc_proj}/project.pbxproj"
end
def increment_build_number project_file
proj_data = open(project_file).read
ms = proj_data.match /INFOPLIST_FILE = \"?(.+\.plist)\"?/
fail -1, "Couldn't find an Info.plist!" unless ms and ms[1]
@plist_path = ms[1]
plist_data = open(@plist_path).read
ms = plist_data.match /<key>CFBundleVersion<\/key>\n\s+<string>(\w+)<\/string>/
fail -1, "Couldn't find a current version number!" unless ms and ms[1]
current_version = ms[1].to_i
new_version = (current_version + 1).to_s.upcase.rjust(4, '0')
plist_data.gsub! /(<key>CFBundleVersion<\/key>\n\s+)<string>(\w+)<\/string>/, "\\1<string>#{new_version}</string>"
open(@plist_path, 'w') {|f| f << plist_data}
puts "Preparing build #{new_version}..."
return new_version
end
def read_current_release_notes fname = "Version History.txt"
notes = open(fname, 'r').read
fail -1, "No version history found!" unless notes
lines = []
notes.each_line {|l| break if l.match(/^Build/); lines << l}
return lines.join
end
def write_release_notes version, fname = "Version History.txt"
notes = open(fname, 'r').read
fail -1, "No version history found!" unless notes
open("Version History.txt", 'w') do |f|
f << "Build #{version} - #{Time.now.utc.iso8601}\n"
f << notes
end
end
def build_and_archive version_number
puts "Building target..."
build_dir = `pwd`.chomp + "/build"
configuration = CONFIG['BUILD_CONFIGURATION'] || 'Release'
build_command = "#{CONFIG['DEVELOPER_PREFIX']}/usr/bin/xcodebuild"
build_command += " -workspace \"#{CONFIG['WORKSPACE']}\" -scheme \"#{CONFIG['SCHEME']}\"" if CONFIG['WORKSPACE']
build_command += " -configuration #{configuration} BUILD_DIR=\"#{build_dir}\" clean build"
puts build_command
result = `#{build_command}`
fail $?, result unless $? == 0
puts "Archiving..."
app_paths = Dir["#{build_dir}/#{configuration}-iphoneos/*.app"]
fail -1, "No #{configuration} build found!" unless app_paths.length > 0
app_name = app_paths.first.split("/").last.split(".").first
out_dir = "#{CONFIG['ARCHIVE_DIRECTORY']}/#{app_name}"
result = `mkdir -p "#{out_dir}"`
fail $?, result unless $? == 0
out_path = "#{out_dir}/#{app_name}#{version_number}.ipa"
result = `/usr/bin/xcrun -sdk iphoneos PackageApplication -v "#{app_paths.first}" -o "#{out_path}" --sign "#{CONFIG['DEVELOPER_NAME']}" --embed "#{CONFIG['PROVISONING_PROFILE']}"`
fail $?, result unless $? == 0
dsym_path = Dir["build/#{configuration}-iphoneos/*.app.dSYM"].first
dsym_out_path = "#{CONFIG['ARCHIVE_DIRECTORY']}/#{app_name}/#{app_name}#{version_number}.dSYM"
`mv "#{dsym_path}" "#{dsym_out_path}"`
`rm -rf "#{dsym_out_path}.zip"` # remove any old zip files
`cd "#{CONFIG['ARCHIVE_DIRECTORY']}/#{app_name}" && zip -r #{app_name}#{version_number}.dSYM.zip #{app_name}#{version_number}.dSYM`
`rm -rf build`
puts "Archived to #{out_path}."
return out_path
end
def post_tf_build path, release_notes
puts "Posting to TestFlight..."
url = URI.parse('http://testflightapp.com/api/builds.json')
File.open(path) do |ipa|
req = Net::HTTP::Post::Multipart.new url.path, 'file' => UploadIO.new(ipa, "application/octet-stream", path.split("/").last),
'api_token' => CONFIG['TF_API_TOKEN'],
'team_token' => CONFIG['TF_TEAM_TOKEN'],
'notes' => release_notes,
'notify' => 'True',
'distribution_lists' => CONFIG['TF_DISTRIBUTION']
res = Net::HTTP.start(url.host, url.port) {|http| http.request(req)}
fail res.code.to_i, res.body if res.code.to_i < 200 or res.code.to_i > 300
result = JSON.parse res.body
return result['install_url']
end
end
def post_hockey_build ipa_path, release_notes
puts "Posting to HockeyApp..."
fail -1, "No HOCKEY_APP_ID found in build settings!" unless CONFIG['HOCKEY_APP_ID']
hockey_app_id = CONFIG['HOCKEY_APP_ID']
fail -1, "No HOCKEY_APP_TOKEN found in build settings!" unless CONFIG['HOCKEY_APP_TOKEN']
hockey_app_token = CONFIG['HOCKEY_APP_TOKEN']
dsym_path = ipa_path.gsub(/\.ipa$/, ".dSYM.zip")
fail -1, "No dSYM found!" unless File.exists? dsym_path
url = URI.parse("https://rink.hockeyapp.net/api/2/apps/#{hockey_app_id}/app_versions")
File.open(dsym_path) do |dsym|
File.open(ipa_path) do |ipa|
req = Net::HTTP::Post::Multipart.new url.path, 'ipa' => UploadIO.new(ipa, "application/octet-stream", ipa_path.split("/").last),
'dsym' => UploadIO.new(dsym, "application/octet-stream", dsym_path.split("/").last),
'notes' => release_notes,
'notes_type' => "1",
'notify' => "1",
'status' => "2"
req['X-HockeyAppToken'] = hockey_app_token
http = Net::HTTP.new(url.host, url.port)
http.use_ssl = true
res = http.request(req)
fail res.code.to_i, res.body if res.code.to_i < 200 or res.code.to_i > 300
result = JSON.parse res.body
return result['public_url']
end
end
end
def git_tag tag
`git commit -am "#{tag}"`
`git tag #{tag}`
end
begin
post_to = ARGV.count > 0 ? ARGV[0] : "testflight"
fail -1, "Specify either 'testflight' or 'hockey'." unless %w{testflight hockey}.include? post_to
check_git_status
xc_proj = project_file_path
fail -1, "No project file found!" unless xc_proj
new_build_number = increment_build_number xc_proj
these_release_notes = read_current_release_notes
ipa_path = build_and_archive new_build_number
install_url = (post_to == 'testflight') ? post_tf_build(ipa_path, these_release_notes) : post_hockey_build(ipa_path, these_release_notes)
write_release_notes new_build_number
git_tag ipa_path.split("/").last.split(".").first
puts "Successfully posted build #{new_build_number}.\n#{install_url}"
rescue SystemExit
rescue Exception => e
fail -1, e
end
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment