DEV Community 👩‍💻👨‍💻

Amir
Amir

Posted on

Flutter: Automate iOS deployment and versioning

Deploying for iOS is typically a tedious and time-consuming task. You have to open the Xcode, change the version name and number, get an archive, press a bunch of buttons, wait for each step and finally hit upload.
Today, we'll use Python to automate this process.

Note: This tutorial is for Flutter 3+, and I'm automating it with Python 3. I also worked on a bash version, but it's incomplete. You can get the entire script and bash version from this gist

Image description

3rd Party tools

There are a lot of articles and tools like Fastlane or Codemagic Cli that can help you to automate this process.
We are not going to use them in this tutorial. We do it ourselves, which will give us more flexibility in the long run.


In some projects, devs always release from the main branch, and it's easy to have the latest version number in the pubspec.yaml file and keep it update.
In other projects, you have to send a TestFlight version from different feature branches, and each time you want to deploy a new version, you must check AppStore Connect to see what the most recent version is.

Step 2 to 4 is about automating this problem. In these sections, we will request to get the latest available version from the Apple server and ask the developer what version they want to set.

Step 1: Configure Xcode

Before automating this process, we must make sure that we can build our app manually and project settings are correct. I will not include these steps in this tutorial because flutter documentation explained it very well.

Step 2: Get the API Key from Apple

To get the latest version from Apple's server, we can use App Store Connect API. But before we can use this API, We must first request to get access to API Key from Users and Access > Keys > App Store Connect API. This action is only available to the Account Holder role.
App Store Connect API page

After requesting access, you need to generate a new API Key.
Generate new API dialogue

Hit the (+) button, Enter a name and choose the access. In the access field, make sure to select the Developer role because it has the ability to upload IPA files to the Apple Store.

Generated API Key

After generating API Key, you can see it in the Active section. Click on Download API Key to get the .p8 file.

Step 3: Create JWT

After obtaining all required credentials, it is time to generate a JWT token for use with the Apple Store Connect API.

Note: You can find your App ID from the URL when opening the app page.
For example, In appstoreconnect.apple.com/apps/1555XXXXX, 1555XXXXX is your App ID

To begin, declare credentials in the code as follows:

ISSUER_ID = '9f4xxxx-xxxx-xxxx-xxxx-xxxxxxxxx'
KEY_ID = '2F9XXXXXXX'
PRIVATE_KEY_PATH = './private_keys/AuthKey_2F9XXXXXXX.p8'
APP_ID = '1500000000'
Enter fullscreen mode Exit fullscreen mode

With this information, we can now construct JWT as follows:

EXP_IN_SEC = 60

with open(PRIVATE_KEY_PATH, 'r+b') as f:
    private_key = f.read()

expiration_time = datetime.datetime.now(tz=datetime.timezone.utc) + datetime.timedelta(seconds=EXP_IN_SEC)
encoded_jwt = jwt.encode(
    {
        'iss': ISSUER_ID,
        'exp': expiration_time,
        'aud': 'appstoreconnect-v1'
    },
    private_key,
    algorithm='ES256',
    headers={
        'alg': 'ES256',
        'kid': KEY_ID,
        'typ': 'JWT',
    }
)
Enter fullscreen mode Exit fullscreen mode

Note: In this section, we use a library to generate JWT, which must be installed as follows: 'pip install pyjwt'

Step 4: Get the last version

Now we can use the JWT we generated earlier to get the most recent TestFlight build.

BASE_URL = 'https://api.appstoreconnect.apple.com/v1'
headers = {'Authorization': f'Bearer {encoded_jwt}'}

r = requests.get(f'{BASE_URL}/preReleaseVersions', headers=headers, params={'limit': 1, 'filter[app]': APP_ID}).json()
build_id = r['data'][0]['id']
build_name = r['data'][0]['attributes']['version']

r = requests.get(f'{BASE_URL}/preReleaseVersions/{build_id}/builds', headers=headers).json()
build_number = r['data'][0]['attributes']['version']

print(f'App version: {build_name}')
print(f'Build number: {build_number}')
Enter fullscreen mode Exit fullscreen mode

We want the developer to determine the next version after we get the last build number (like 154) and build name (like 2.3.0). Most of the time, we only need to increase the build number by one, which is why I included the if-else clause. If you leave the build number blank and press enter, it will add one to the previous build number.

new_build_name = input('Enter new app version: ')
new_build_number_str = input('Enter new build number(enter to auto increment): ')

if new_build_number_str == '':
    new_build_number = int(build_number) + 1
else:
    new_build_number = int(new_build_number_str)

if new_build_name == '':
    exit('No new version number entered')
Enter fullscreen mode Exit fullscreen mode

Step 5: Build the app

Now it's time to build the app, and we no longer require Python. We need the power Flutter cli to rescue us :)

To run the terminal commands from python, we can use subprocess.run in python.

def run(command: str, check: bool = None, output: bool = True, cwd: str = None):
    return subprocess.run(shlex.split(command), check=check, stdout=subprocess.DEVNULL if not output else None, cwd=cwd)
Enter fullscreen mode Exit fullscreen mode

Before building the IPA file, we have to make sure all the packages are updated and we have everything we need to build the project.

# Update dependencies
print('Getting pub...')
run('flutter pub get', check=True, output=False)
print('Installing pod dependencies...')
run('rm ios/Podfile.lock', check=True, output=False)
run('pod install --repo-update', cwd="ios/", check=True, output=False)
Enter fullscreen mode Exit fullscreen mode

After that, we use the following function to read the pubspec.yaml file and change the version: x.x.x+yyy with the new version we specified earlier in the input.

def change_pub_version(version: str):
    with open('pubspec.yaml', 'r') as f:
        data = f.read()
        data = re.sub(r'^(version:\s*)(.+)', f'version: {version}', data, flags=re.MULTILINE)
        f.close()

    with open('pubspec.yaml', 'w') as f:
        f.write(data)
        f.close()
Enter fullscreen mode Exit fullscreen mode

We can also remove the build folder to ensure there is nothing else in there before asking Flutter to create the IPA file for us. If you are using Flutter version 3+, You don't need to do anything; Flutter handles generating IPA for you.

# you can also specify version when building like this:
# `flutter build ipa --build-name {new_app_version} --build-number {new_build_number}`
change_pub_version(f'{new_build_name}+{new_build_number}')
run('rm -rf build/ios')
run(f'flutter build ipa --no-pub', check=True)
Enter fullscreen mode Exit fullscreen mode

Note: by default, flutter auto-generates some parameters from the pubspec file when building. If you haven't changed anything in the Xcode project, it will get the version from those parameters.
If it doesn't work for you, you can follow this article or this answer to fix it.

Step 6: Upload it to AppStore

We successfully generated an IPA file in the previous step. It's now time to upload it. At this point, there are two options for manually uploading it, but what's the fun in that?
We use altool to upload the file for us. We also consider that uploading or verifying the file may fail, and in that case, we open the folder and the Transporter application, so we can do it manually and determine what caused the error.

e = run(f'xcrun altool --upload-app --type ios -f build/ios/ipa/*.ipa --apiKey {KEY_ID} --apiIssuer {ISSUER_ID}')
if e.returncode != 0:
    run('open build/ios/ipa')
    run('open -n /Applications/Transporter.app')
Enter fullscreen mode Exit fullscreen mode

Step 7: Add script to IDE

Now that we've finished our Python script, let's make it easier to run. You can, of course, run it from the terminal, but there are better ways.

Android studio

It is possible to add it as a run configuration and press run whenever you need it. Ensure that the Working directory is the root path of your project.

Image description

VSCode

You can store it in the .vscode/launch.json file as a run configuration.

{
    "version": "0.2.0",
    "configurations": [
        {
            "name": "Deploy iOS",
            "type": "python",
            "request": "launch",
            "program": "scripts/deploy_ios.py",
            "console": "integratedTerminal"
        }
    ]
}
Enter fullscreen mode Exit fullscreen mode

Step 8: Get rid of "Manage compliance"

After uploading each TestFlight version to AppStore Connect, you will see the Manage compliance warning. Apple won't roll out the new release for TestFlight users until you answer those two questions in the box.
Image description

This can be annoying when you want an automated process from start to finish. Luckily, this answer will help you get rid of it.

Bouns: Flavors

Flavors are an excellent way to separate different environments. I recommend reading this article for implementing them in Flutter.

After adding different flavors to your project, you can create a new app in AppStore Connect for the dev version with different bundle identifier.

In this gist, I added an example for when you use different bundle identifier and you need to deploy them separately.

Top comments (1)

Collapse
 
behradkhadem profile image
BehradX

Keep up the good work!

🌚 Life is too short to browse without dark mode