Introduction to Objective-C Modules

Current State

Unless you’ve written C in the past, your closest encounter with the preprocessor and its code inclusion is probably the #import statement. But before #import, there was #include.

#include

The preprocessor command #include is a clever little trick. It basically tells the preprocessor to treat the contents the file included as if the entire file actually appeared at the point of the #include. That explanation may seem a little confusing, so let’s just look at an example. Let’s say we have a file, IncludeMe.h:


// IncludeMe.h

#define kMyConstantNumber 42

#define kMyConstantBoolean true

Now, we write a little C program that uses the constants defined in the IncludeMe header file:


// MyProgram.c

#include <stdio.h>

#include "IncludeMe.h"

printf("The constant is %d and the boolean is %d", kMyConstantNumber, kMyConstantBoolean);

How does MyProgram.c know what the values of kMyConstantNumber and kMyConstantBoolean are in order to print them out from printf()? Well, what’s really happening is the preprocessor is going in and injecting the contents of IncludeMe.h into MyProgram.c. So, what the compiler actually sees when it’s compiling your program is the following:


// MyProgram.c

#define kMyConstantNumber 42

#define kMyConstantBoolean true

printf("The constant is %d and the boolean is %d", kMyConstantNumber, kMyConstantBoolean);

Sure, this is a trivial example, but it should make it pretty obvious exactly what the preprocessor is doing. Actually, the above example isn’t entirely true. To see what the preprocessed file really looks like, fire up Xcode and create a new C/C++ file. In terminal, navigate to the directory containing that file (use the cd command to change directories and navigate to the file). Then type gcc -E filename.c and observe all of the code that gets spit out to the terminal window. By default, all new C files have stdio.h included. This is the header file for all of the basic IO functions available to all C programs (such as the printf() function you saw above). The preprocessor sees that your C file includes stdio.h, and so it includes all of the code in stdio and makes it available to your C program. If you scroll aaaall the way to the bottom of the output, you’ll see the code you actually wrote. #include placed all of the code from stdio in your file as if you had copy/pasted it there yourself.

As a side note, notice that we wrapped our included header file in double quotes (” “). This tells the preprocessor “look for IncludeMe.h in the same directory as MyProgram.c”. However, stdio.h is wrapped in angle brackets (< >). This tells the preprocessor to look for this header file in the directory with all of the system headers.

Recursive Includes

So, this preprocessor #include directive is great. It lets you modularize your code, include system headers, and fosters reusability. But what happens when you have a pair of files that look like this:


// FirstFile.h

#include "SecondFile.h"

/* Some code */

// SecondFile.h

#include "FirstFile.h"

/* Some other code */

Well, the preprocessor first goes out and sees that FirstFile.h wants to include SecondFile.h inside of it. But when it goes to do that, it sees that SecondFile.h also tries to include FirstFile.h, which includes SecondFile.h, which includes FirstFile.h, which includes….ok, well, you get the picture. This is called a Recursive Include.

#include vs. #import

The recursive include is the problem that Objective-C tried to solve with the introduction of the #import directive. Using #import, a file would be guarded against recursive includes by first checking to make sure the included file was not already defined. If it was not, the file would be included, otherwise it would be skipped. Traditional C headers also support this in the form of header guards:


#ifndef MyFile_h

#define MyFile_h

// Some code

#endif

The two essentially do the same thing, however Objective-C classes and frameworks should use #import and not #include.

Introducing @import

Back in November in 2012, Doug Gregor of Apple gave a presentation at the LLVM Developers Meeting requesting that modules, a solution to the problems inherent to preprocessor #imports and #includes, be introduced. Modules, Gregor argued, solve two problems the current preprocessor implementation faces:

  1. Fragility
  2. Scalability

With regards to fragility, a simple example exposes how ordering with preprocessor #includes and #imports matters greatly in the end. Let’s say we have the following Objective-C file:


// MyFile.h

#define strong @"this won't work"

#import <UIKit/UIKit.h>

@interface MyFile : NSObject

@property (nonatomic, strong) NSArray *anArray;

@end

What happens after the preprocessor is done doing its work? Your header file now looks like this:


// MyFile.h

#define strong @"this won't work"

// UIKit imports

@interface MyFile : NSObject

@property (nonatomic, @"this won't work") NSArray *anArray;

@end

Notice that we’ve overridden the definition of the strong keyword with something the compiler doesn’t know how to handle.

The other issue, scalability, should be apparent from the above description about #include. #include and #import are both textual inclusions — they are a glorified copy/paste transaction. The contents of the included file are simply pasted inline where the #include or #import statement was placed. Furthermore, any files included in that file also have their contents pasted into the original file, and so on and so forth until the entire #include/#import tree is traversed. This results in a multiplicative compile time between source files and headers. Now, you would think that for as long as C and Objective-C have been around, someone somewhere would have tried to tackle this problem. And you’d be right. Pre-compiled headers (.pch) have been in use for years to combat the scalability issue. Add an #include/#import statement here, headers are compiled into a single on-disk representation of all files in the .pch, and those headers are included in every source file in your project. However, even .pch files come with their own set of problems:

  1. Most developers don’t maintain their .pch files. They soon become unruly and unmanageable
  2. It’s difficult for developers new to a project to understand how files are related if everything is in the .pch file
  3. Sometimes you just don’t want a file included everywhere

Modules break away from this textual inclusion model and instead act as an encapsulation of what a framework is, as opposed to just shoving the headers into your source files. Think of it as making an API of the framework available to your source file. With modules, a framework is compiled once into an efficient, serialized representation that can be efficiently imported when the library is used. Additionally, it ignores preprocessor state within the source file, meaning that you can’t override the definition of a keyword in a module just because you #define something with the same name prior to the import.

But Apple, in typical fashion, has taken modules even further. Think about it: if you import the MapKit framework, why should you also have to tell Xcode to link against the MapKit framework? With modules, you get support for autolinking frameworks by default!

And how do we get all of this header-caching-auto-linking goodness? Say hello to @import. Simply using the @import declaration will kick off the new module parsing and caching. And you can still do selective imports through the use of the dot syntax. So, for instance, to import only the MKMapView classes and none of the other classes in the MapKit module, you simply say

@import MapKit.MKMapView

Done.

Well that’s great, but now I have to go through each of my #import statements and replace them with @import? No! Apple is taking care of this for you, as long as you opt in. To opt in, make sure you turn on the Enable Modules (C and Objective-C) build setting. Additionally, you can turn on/off the auto-linking of frameworks here, as well.

@import Build Setting

@import Build Setting

 

 

 

 

And that’s it! How does this work, you ask? Module Maps. Module Maps are a way for modules to, well, map back to their header counterparts. Then, a separate compiler instance is spawned and the headers from the Module Map are parsed. A module file is written, and then that module file is loaded at the import declaration. As before, that module file is cached for later re-use (where ever you may import the same module).

Defining a Module

Defining a module is a relatively easy process. From Apple’s own presentation to the November LLVM Meeting, here is an example of how to define the C stdio library as a module:

Module for stdio.c

Module for stdio.c

The export keyword specifies the module name. The dot (.) indicates a submodule, so, in this case, stdio is a submodule of the std module. The public: keyword denotes the access to the API. In other words, which variables, methods, etc. will be publicly available. Anything defined outside of this is private to the module and remains that way. And that’s about it!

NOTE: At this time, modules are only available for Apple’s frameworks. and have been implicitly disabled for C++.

Performance Improvements

All of this @import stuff is really cool, in theory. But how does it stack up in practice? Doug Gregor includes an example in his presentation of the difference in the number of lines of compiled code using the traditional preprocessor macro versus the new @import directive. For the ubiquitous “Hello World” C program, the original source code file is a mere 64 lines of code. After the preprocessor has done its include of the stdio.h header, that number jumps to 11,072 lines. That’s 173 times the number of lines in the actual source file. That’s huge! Now let’s say each of your source files imports the stdio.h header (as almost all C programs do). You’re talking about adding over 11,000 lines of code to each file you add. From a mathematical perspective, that leads to an M x N compile time, if you imagine M source files and N headers. Using modules, the stdio module is parsed only once and then cached, dramatically reducing the number of lines in your processed source code files. Now, for smaller projects, the difference in compile time is negligible, and you probably won’t notice much of a benefit (aside from the conveniences of things like the auto-linking of frameworks). For larger projects, however, you’re looking at potential compile time improvements of a couple of percentage points or more! Either way, this is a really interesting and welcome addition to the LLVM compiler, and as the community continues to expand on what a module is and does, the benefits of using modules will continue to grow.

Advertisements

Automatically Build, Archive, and Distribute to TestFlight

For the first official tutorial post, I thought I’d write about something I never saw or read too much about when I first set out to accomplish this task.

A few months ago, I wanted to streamline my TestFlight distribution process. For those of you who don’t know, TestFlight is a service that makes beta list management, provisioning, and distribution an absolute dream for iOS developers. Gone are the days of mailing out .ipa files and managing your own distribution lists. If you don’t have a TestFlight account already, sign up; it’s free!

Once you have an account, you’ll need your API token and Team Token. You can find those here and here, respectively.

Now then, on to automating!

AutoBuild

Let’s first create a new Xcode project. Nothing fancy here, just a Single View iPhone application. Go ahead and name it AutoBuild. For ease-of-file paths, feel free to save it to the Desktop. We don’t actually need to do anything with the application code, specifically, but we’re going to make a few changes to the project settings itself.

Build Configurations

Build configurations are an often-overlooked but pretty handy feature of Xcode. Using build configurations, you can set various build settings without having to change each individual setting between things like development and production builds. By default, Xcode creates two configurations for you: Debug and Release. To see what build configurations you currently have, select your project in the Project Navigator on the left, and then switch from the AutoBuild Target to the AutoBuild Project, like below:

Build Configurations Screenshot

Figure 1. Build Configurations

Let’s add a new Build Configuration for TestFlight. This will allow us to set specific build settings (such as which Provisioning Profile to use) for when we build for TestFlight releases. Start by clicking the little “+” button in the Configurations section, and choose “Duplicate ‘Release’ Configuration”. Name it “TestFlight”.

Now we need to tell Xcode that when we archive, we want to use this new TestFlight configuration. In the top of the Xcode window, click on the AutoBuild scheme (this is next to the drop down that lets you choose a different build target. It doesn’t look like it, but that’s actually two different buttons) and select “Edit Scheme”. In the window that appears, select “Archive” in the left pane. This is where we tell Xcode which configuration to use. From the drop down list, choose “TestFlight”. Your screen should now look like this:

Figure 2. TestFlight Configuration

Figure 2. TestFlight Configuration

Go ahead and hit “OK”. The last thing we’ll need to do here is set the Provisioning Profile for the TestFlight configuration. Go ahead and log into your Apple iOS Developer Portal and visit the Provisioning Portal. Click on “Provisioning” and create a new wildcard Provisioning Profile called “AutoBuild Profile”. Creating this in the Development tab is fine, even though TestFlight’s documentation says (or used to say) to create an AdHoc Distribution Profile. This isn’t actually necessary for uploading to TestFlight (and, in fact, you would need a Development profile if you wanted to test Push Notifications with a TestFlight-distributed app). Download that new profile and move it to your Desktop, as well.

Add this new profile to your application by dragging and dropping it into the Xcode Organizer (shift+cmd+2). If we go back to view the Build Settings for our Target, this provisioning profile should have been automatically selected for the TestFlight configuration. If it wasn’t, just select the new profile for the TestFlight setting.

The last thing we need to be sure of is that we have the Command Line Tools installed for Xcode. To check this, in the menu bar select Xcode → Preferences, and select the Downloads tab. The line for Command Line Tools should say “Installed”. If it doesn’t, install them now.

As for the automation, this will be done using Automator. This is done for no particular reason, but if you’ve never used Automator before, this may be a good time to get somewhat familiar with it.

Create The Automator Application

Begin by opening Automator and choosing to create a new Application.

New Automator Application Image

Figure 3. A new Automator Application

Once that opens, you’ll see that there are a whole slew of pre-defined actions you can choose from. In true Apple fashion, you can simply drag-and-drop these actions out onto the palette to work with them. And that’s exactly what we are going to do! We only need one action for this tutorial: “Run Shell Script”. We’ll use the Shell Script to actually run our build/archive/load to TestFlight commands. Go ahead and pull out this Action, so that your workspace now looks like Figure 4 below:

Figure 4. Blank Shell Script

Figure 4. Blank Shell Script

“Shell” We Script Some More?

There are a few things you’re going to need to know before we get going on this shell script. You’ll need to know:

  1. The location of your project. This should be /Users/[your_user_name]/Desktop/AutoBuild.
  2. The location of the Provisioning Profile for your application. This should be /Users/[your_user_name]/Desktop/AutoBuild.mobileprovision
  3. The name of the developer associated with your Provisioning Profile. This will look something like “John Doe (8FG28KU8R)”
  4. The name of the configuration you are using when you Archive. We created the TestFlight configuration earlier.

With this information in hand, we can begin our script! In the “Run Shell Script” section of our Automator app, let’s start by defining some variables. For this script, it will save us from subjecting ourselves to the possibility of typing the same file path differently in multiple places, as well as make the final product look at least slightly more legible. When writing a shell script, use to denote a comment. So, let’s start with a comment:

# Variables

Congratulations, you’re a shell-scripting master! Ok, maybe not quite yet, but we’ll get there. The first two variables we are going to define are the project directory and the build configuration. I tend to use ALL_CAPS_VARIABLES_SEPARATED_BY_UNDERSCORES. This seems to be a reasonable convention, and so I have stuck with it. Unless you’re a caps lock-happy programmer, this should make it fairly simple to determine what’s a variable and what isn’t. So, the next two lines of your script should look something like this:

# Variables
PROJ_DIR=/Users/jmstone/Desktop/AutoBuild
CONFIGURATION=TestFlight

Everyone still with me? Good. Next we’ll define some variables that are needed during the archive process: the project name and the scheme name. Unless you’ve explicitly created a new scheme, your project name will suffice here. I’ve broken it into two separate variables either way:

#Archive Variables
PROJ_NAME=AutoBuild
SCHEME_NAME=AutoBuild

Next come the variables for the .ipa file. When you create an archive, Xcode takes the liberty of packaging up files such as the .app file (your actual application), your .dSYM file (used for symbolicating crash reports), and your info.plist. The .app file is simply “Your Project Name”.app. The next set of variables are all necessary for creating the .ipa file. Once you’ve had a chance to look at them, I will explain each one:

#IPA Variables
APP_NAME=AutoBuild.app
IPA_LOC=/Users/jmstone/Desktop
IPA_NAME=AutoBuild.ipa
DEVELOPER_NAME="John Doe (8FG28KU8R)"
PROVISION_PROFILE=/Users/jmstone/Desktop/AutoBuild.mobileprovision

The APP_NAME variable should be self explanatory. It’s the name of your app, in this case, AutoBuild.app. IPA_LOC is a variable denoting where you want to save the .ipa file to. Here, we just save it to the Desktop, but you could save it to a specific folder to keep things organized. IPA_NAME is just the name you want to give to your .ipa file. DEVELOPER_NAME is the name associated with the PROVISION_PROFILE, which is simply the location to the Provisioning Profile denoted in the Build Settings of your target. To make sure we’ll all on the same page, our script so far should look like this:

# Variables
PROJ_DIR=/Users/jmstone/Desktop/AutoBuild
CONFIGURATION=TestFlight

#Archive Variables
PROJ_NAME=AutoBuild
SCHEME_NAME=AutoBuild

#IPA Variables
APP_NAME=AutoBuild.app
IPA_LOC=/Users/jmstone/Desktop
IPA_NAME=AutoBuild.ipa
DEVELOPER_NAME="John Doe (8FG28KU8R)"
PROVISION_PROFILE=/Users/jmstone/Desktop/AutoBuild.mobileprovision

There’s just one more variable we need. Xcode, in its infinite wisdom, sticks our archive in a folder based on the date, YYYY-MM-DD. Assuming we distribute our .ipa file on the same day that we archive it (and that wouldn’t be the case using this script only if somehow your script got to the archive step before 12:00 am and got to the .ipa step after 12:00 am), we can use the date command, with some options, to grab today’s date in the format we need:

ARCH_FOLDER=`date '+%Y-%m-%d'`

Now, for those of you familiar with UNIX commands, the remainder of the script should look reasonably familiar. In most cases, we are simply placing our variables in the correct places. First, we need to change directory (cd) into our top-level project directory

cd ${PROJ_DIR}

This is our first exposure to referencing the variables we created earlier. It’s very simple: just wrap the variable name in ${ }. Done.

The command line tools we installed earlier add a series of bash commands in /usr/bin, including xcodebuild and xcrun. You can navigate to /usr/bin and type ‘ls’ to list all of the bash commands available to you (this is where commands such as git, cd, ls, ruby, etc. are all stored). The documentation Apple provides for these two commands actually isn’t terrible, considering the fact that most people build and archive from within Xcode itself. You can find the documentation for xcodebuild here, and for xcrun here.

Now that we’re inside of our project directory, our first command is going to be the one that builds our project. That line looks like this:

xcodebuild -target ${PROJ_NAME} -sdk iphoneos6.0 -configuration ${CONFIGURATION}

We’re using the xcodebuild command, along with the options -target, -sdk and -configuration, to build our project (${PROJ_NAME}) using the iOS 6.0 SDK. Note that this is uses the iOS SDK, not the iOS deployment target, so even if you’re deploying back to, say, 5.0, you’re still building with the iOS 6 SDK. If for some reason you’re not using the latest SDK, replace this part with the SDK you are using. If you’re unsure of the SDK you are using, Xcode defaults to the latest SDK available, which as of this writing is 6.0. We’ve also told the xcodebuild command to build using our TestFlight configuration.

After the build is complete, the next step will be to archive. This is done with the command

xcodebuild archive -project "${PROJ_NAME}.xcodeproj" -scheme ${SCHEME_NAME} -configuration ${CONFIGURATION}

Here, the archive command is a buildaction on xcodebuild. Another buildaction is, ironically enough, build, and we could have specified that in our first command to xcodebuild. However, build is the default buildaction, so it’s not necessary. Other buildactions include install and clean. In this case, we’re telling xcodebuild that we want to archive the project ${PROJ_NAME} with scheme ${SCHEME_NAME} and, again, use the TestFlight configuration ${CONFIGURATION}.

Now let’s navigate to the folder containing our Archive

cd ~/Library/Developer/Xcode/Archives
cd ${ARCH_FOLDER}
LATEST_FILE=`ls -rt | tail -1`
cd "$LATEST_FILE/Products/Applications"

This changes to the Archives directory within the Xcode directory in ~/Library/Developer (~ is a short cut to your home directory). Then we change directory to the folder named using today’s date (e.g. 2012-12-11). Next, we create a variable called LATEST_FILE, which grabs the file name of the most recently created archive, because if we were to run this script multiple times in a single day, each archive would be placed in the same folder. For those new to UNIX, ‘ls’ is used to list the contents of a directory. The -r flag reverses the order in which we list those contents, and the -t flag sorts them based on time. We then use ‘ | tail -1’ to put the most recently modified file name into the LATEST_FILE variable instead of to stdout. Finally, we change directory to the Applications folder of this archive (you can view the contents of the archive in Finder by navigating to the .xcarchive file, right clicking, and selecting ‘Show Package Contents’). Make sure you wrap this in quote marks, to account for spaces.

Next, we’ll use xcrun to package our Xcode archive into a .ipa file. The following command is used for that, and should give a little insight into how the packaging of an .ipa file actually works.

xcrun -sdk iphoneos PackageApplication -v ${APP_NAME} -o ${IPA_LOC}/${IPA_NAME} --sign ${DEVELOPER_NAME} --embed ${PROVISION_PROFILE}

Here, we’re basically just telling xcrun to package our application. Along with that, we pass in some variables such as the Application name, the location we want to save the .ipa file to, which developer to sign the file with, and which provisioning profile to use. In Xcode 4.x, this is equivalent to clicking “Distribute” in the Archive tab of the Organizer and selecting which Developer/Provisioning Profile to use.

So, that’s it for the build/archive/package process in Xcode. If you look at our script now, it should look something like this:

# Variables
PROJ_DIR=/Users/jmstone/Desktop/AutoBuild
CONFIGURATION=TestFlight

#Archive Variables
PROJ_NAME=AutoBuild
SCHEME_NAME=AutoBuild

#IPA Variables
APP_NAME=AutoBuild.app
IPA_LOC=/Users/jmstone/Desktop
IPA_NAME=AutoBuild.ipa
DEVELOPER_NAME="John Doe (8FG28KU8R)"
PROVISION_PROFILE=/Users/jmstone/Desktop/AutoBuild.mobileprovision

ARCH_FOLDER=`date '+%Y-%m-%d'`

cd ${PROJ_DIR}

xcodebuild -target ${PROJ_NAME} -sdk iphoneos6.0 -configuration ${CONFIGURATION}
xcodebuild archive -project "${PROJ_NAME}.xcodeproj" -scheme ${SCHEME_NAME} -configuration ${CONFIGURATION}

cd ~/Library/Developer/Xcode/Archives
cd ${ARCH_FOLDER}

LATEST_FILE=`ls -rt | tail -1`

cd "$LATEST_FILE/Products/Applications"

xcrun -sdk iphoneos PackageApplication -v ${APP_NAME} -o ${IPA_LOC}/${IPA_NAME} --sign ${DEVELOPER_NAME} --embed ${PROVISION_PROFILE}

So we can finally see some actual progress, let’s go ahead and run what we have so far. Once the script is finished running, you can open the Archive tab in the Organizer, and you should see your new Archive listed (Figure 5). You should also see an AutoBuild.ipa file on your desktop.

Figure 5. The Archive in Organizer

Figure 5. The Archive in Organizer

Next, let’s cd to our .ipa file location

cd ${IPA_LOC}

Now we are going to use the TestFlight API and the cURL command to upload our new .ipa file to TestFlight. As I said at the beginning of this post, you’re going to need your API Token and Team Token. The API has links to both of those, so if you didn’t grab them earlier, just visit the API to get there quickly. Here is the last command of our script:

curl http://testflightapp.com/api/builds.json -F file=@${IPA_NAME} -F api_token='[YOUR_API_TOKEN]' -F team_token='[YOUR_TEAM_TOKEN]' -F notes='API upload from script!'

The -F flag we’re using here is for the cURL utility, and basically mimics you filling out a form and clicking the “Submit” button. It acts essentially like a POST using the Content-Type multipart/form-data. Fill in [YOUR_API_TOKEN] and [YOUR_TEAM_TOKEN] with the tokens you got from TestFlight. The last little bit is to add any release notes you want in the notes= field.

The entire script should now look like this:

# Variables
PROJ_DIR=/Users/jmstone/Desktop/AutoBuild
CONFIGURATION=TestFlight

#Archive Variables
PROJ_NAME=AutoBuild
SCHEME_NAME=AutoBuild

#IPA Variables
APP_NAME=AutoBuild.app
IPA_LOC=/Users/jmstone/Desktop
IPA_NAME=AutoBuild.ipa
DEVELOPER_NAME="John Doe (8FG28KU8R)"
PROVISION_PROFILE=/Users/jmstone/Desktop/AutoBuild.mobileprovision

ARCH_FOLDER=`date '+%Y-%m-%d'`

cd ${PROJ_DIR}

xcodebuild -target ${PROJ_NAME} -sdk iphoneos6.0 -configuration ${CONFIGURATION}
xcodebuild archive -project "${PROJ_NAME}.xcodeproj" -scheme ${SCHEME_NAME} -configuration ${CONFIGURATION}

cd ~/Library/Developer/Xcode/Archives
cd ${ARCH_FOLDER}

LATEST_FILE=`ls -rt | tail -1`

cd "$LATEST_FILE/Products/Applications"

xcrun -sdk iphoneos PackageApplication -v ${APP_NAME} -o ${IPA_LOC}/${IPA_NAME} --sign ${DEVELOPER_NAME} --embed ${PROVISION_PROFILE}

cd ${IPA_LOC}

curl http://testflightapp.com/api/builds.json -F file=@${IPA_NAME} -F api_token='[YOUR_API_TOKEN]' -F team_token='[YOUR_TEAM_TOKEN]' -F notes='API upload from script!'

Checking Our Work

And that’s it! We’re done. If we run the script again, we’ll see a few things. For one, we should have a new entry in our list of Archives in the Xcode Organizer. Better yet, if we visit our list of apps on TestFlight, we should see our AutoBuild app!

App in list

Figure 6. AutoBuild in the TestFlight list of apps

Better yet, if we click on the app, and then our only build in the list of Builds, we can go to Build Information and, in the release notes, see the notes we had in our script!

App with comment

Figure 7. AutoBuild with the comments we supplied

Conclusion

And there we have it! With a little bit of shell scripting, we were able to automate the entire build/archive/package process in Xcode and uploaded it to TestFlight! There’s a lot more power in the TestFlight API than simply uploading the build to TestFlight. For instance, if you have distribution lists set up on TestFlight, you can denote which of those lists receive the build. Even better, you can set “notify” to true in order to automatically notify eligible users that a new build is available!

As I stated at the beginning of this post, this is my first tutorial. If you have some ways to make this script better, or suggestions about how I could make future tutorials more clear, please feel free to leave them in the comments!