Flutter: Automate iOS deployment and versioning
Tags: flutter, ios, tutorial, automation
Published: 2022-06-06 09:42:49

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](https://gist.github.com/SamadiPour/34126da2c8bae567b3b0dd8a83bc8b66)
 
![Image description](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/sxknq22xavz3y5kapyu2.png) 
 

## 3rd Party tools

There are a lot of articles and tools like [Fastlane](https://fastlane.tools/) or [Codemagic Cli](https://docs.codemagic.io/cli/codemagic-cli-tools/) 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](https://docs.flutter.dev/deployment/ios) 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](https://developer.apple.com/documentation/appstoreconnectapi). 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](https://appstoreconnect.apple.com/access/api). This action is only available to the Account Holder role.
![App Store Connect API page](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/6vp4el69if3tgsqphvku.png)

After requesting access, you need to generate a new API Key.
![Generate new API dialogue](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/xxlvxtmm6s2a22iwe264.png)

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](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/tw4ah5wvmz2ghix9x6g5.png)

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:
```python
ISSUER_ID = '9f4xxxx-xxxx-xxxx-xxxx-xxxxxxxxx'
KEY_ID = '2F9XXXXXXX'
PRIVATE_KEY_PATH = './private_keys/AuthKey_2F9XXXXXXX.p8'
APP_ID = '1500000000'
```

With this information, we can now construct JWT as follows:
```python
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',
    }
)
```

> 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.

```python
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}')
```

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.

```python
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')
```

## 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.

```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)
```

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.

```python
# 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)
```

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.

```python
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()
```

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.

```python
# 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)
```

> 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](https://medium.com/karlmax-berlin/versioning-with-flutter-299869e68af4) or [this answer](https://stackoverflow.com/a/63914373/4614391) 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](https://apps.apple.com/us/app/transporter/id1450874784), so we can do it manually and determine what caused the error.

```python
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')
```

## 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](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/nsf5o00pvt585oc8aehk.png)

### VSCode

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

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

## 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](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/leq92xtysbh81dgpkbqy.png)

This can be annoying when you want an automated process from start to finish. Luckily, [this answer](https://stackoverflow.com/a/35842359/4614391) will help you get rid of it.


## Bouns: Flavors

[Flavors](https://docs.flutter.dev/deployment/flavors) are an excellent way to separate different environments. I recommend reading [this article](https://flutterguide.com/flutter-flavor-separating-build-environments-in-flutter-apps/) 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](https://gist.github.com/SamadiPour/34126da2c8bae567b3b0dd8a83bc8b66), I added an example for when you use different bundle identifier and you need to deploy them separately.