Pull requests for the designer: How to implement the workflow for a multi-platform application on Azure DevOps

-

Enabling designers to review design implementations early on in the development process is a great way to improve the flow of getting work done in a project. Plus, it increases the collaboration of designers, engineers, testers, and business stakeholders. In this post, I’ll explain how to technically implement this workflow, using a typical multi-platform application project as an example.

This blog post is the technical counterpart to the article explaining the conceptual side of the pull-requests-for-the-designer equation. If you want to know how to improve user experience and project flow by involving designers early in the review process, go read that one first. Then, come back here and learn how to set up the workflow yourself. Onto the implementation details!

Project and workflow setup

While we will use a Xamarin-powered multi-platform application — specifically: the FlowSuite app we built for our client Bronkhorst — and Azure DevOps as an example, the principles of the project and workflow setup can be applied to basically any user-facing software application setup.

The idea is simple: developers work on a single codebase that contains generic business and data binding logic for all platforms (in our case: iOS, Android, and Windows), and platform-specific code (mostly view-related). This enables us to build several functionally equivalent applications with the minimum amount of code duplication and effort.

At the project workflow level, a designer user interface designs and user experience descriptions aimed at the specific platforms, related to user stories. Engineers get to work on the implementation, trying their hardest to follow the specifications. Once ready for review, they submit their code, create a pull request for it, and wait for review. At that point, a build pipeline picks up the changes, builds all application variants and the implemented design is ready for review by the designer, who can leave comments, optionally ask for rework, and finally approve the change.

Setting up the build pipeline

To enable the described workflow, we have set up an Azure Pipeline in Azure DevOps, tied to our trunk-based code repository. For every commit on a pull-request, release or main branch, a pipeline is created, and for every change to it, a pipeline run is triggered, resulting in several application builds.

Below, you can see the high-level overview of our pipeline definition, written in YAML. It contains the build trigger, necessary build meta information and the build stages. As you can see, we have a non-platform specific build stage for the application foundation, and three separate build stages, one for each platform. Each successful build is then released to our designers via AppCenter, enabling quick delivery and short process times for the review (and optional rework) stage.

trigger:
  - main
  - release/*

variables:
  isRelease: $[startsWith(variables['Build.SourceBranch'], 'refs/heads/release')]
  androidArtifactName: "drop_android"
  iosArtifactName: "drop_ios"
  uwpArtifactName: "drop_uwp"

name: $(GITVERSION.FULLSEMVER)

stages:
  - stage: "Build"
   ...

  - stage: "BuildAndroid"
    dependsOn: Build
    jobs:
      - template: /build-android.yml

  - stage: "BuildIos"
    dependsOn: Build
    jobs:
      - template: /build-ios.yml

  - stage: "BuildUwp"
    dependsOn: Build
    jobs:
      - template: /build-uwp.yml

  - stage: "Production_Releases"
    ...

Enabling iOS builds and distributions

For iOS, some specifics are worth mentioning. First of all, we use two separate provisioning profiles to build our iOS app: one for review builds distributed via AppCenter (the scope of this article), and one for end-user release builds ending up in the Apple App Store. Secondly, we need to be able to identify builds so we can tie them to change requests. For this, we use GitVersion, but any uniquely ID that is traceable to the commit or pull request level would suffice.

In our pipeline configuration we use a simple ‘isRelease’ variable to trigger the applicable build and release task:

jobs:
  - job: Build_iOS
    pool:
      vmImage: "macos-11"
    steps:
      - task: GitVersion@5
        ..
      - task: NuGetToolInstaller@1

      - task: NuGetCommand@2
        ...

      - task: InstallAppleCertificate@2
        inputs:
          certSecureFile: "BronkhorstDistri.p12"
          certPwd: "*****"
          keychain: "temp"

      - task: DownloadSecureFile@1
        name: BronkhorstDistri # The name with which to reference the secure file's path on the agent, like $(mySecureFile.secureFilePath)
        inputs:
          secureFile: BronkhorstDistri.p12

      - task: InstallAppleProvisioningProfile@1
        condition: eq(variables.isRelease, false)
        inputs:
          provisioningProfileLocation: "secureFiles"
          provProfileSecureFile: "Ad_Hoc_Bronkhorst_FlowControl.mobileprovision"

      - task: InstallAppleProvisioningProfile@1
        condition: eq(variables.isRelease, true)
        inputs:
          provisioningProfileLocation: "secureFiles"
          provProfileSecureFile: "Bronkhorst_Flowcontrol_distribution.mobileprovision"

      - task: UpdateiOSVersionInfoPlist@1
        ...

      - task: CopyFiles@2
        ...

      - task: XamariniOS@2
        inputs:
          solutionFile: "**/*.sln"
          configuration: "Release"
          # This value is automatically set by the InstallAppleCertificate task
          signingIdentity: $(APPLE_CERTIFICATE_SIGNING_IDENTITY)
          # This value is automatically set by the InstallAppleProvisioningProfile task
          signingProvisioningProfileID: $(APPLE_PROV_PROFILE_UUID)
          packageApp: true
          args: /p:IpaPackageDir="$(Build.ArtifactStagingDirectory)"

      - task: PublishBuildArtifacts@1
        inputs:
          PathtoPublish: "$(Build.ArtifactStagingDirectory)"
          ArtifactName: "$(iosArtifactName)"
          publishLocation: "Container"

      - task: AppCenterDistribute@3
        condition: eq(variables.isRelease, false)
        displayName: iOS group release
        inputs:
          serverEndpoint: "BronkhorstAppCenter"
          appSlug: "Luminis/Bronkhorst.FlowControl.App.iOS"
          appFile: "$(Build.ArtifactStagingDirectory)/Bronkhorst.FlowControl.iOS.ipa"
          buildVersion: "$(GITVERSION.FULLSEMVER)"
          releaseNotesOption: "input"
          releaseNotesInput: "$(GITVERSION.FULLSEMVER)"
          destinationType: "groups"
          distributionGroupId: "XXXX-XXXX-XXXX-XXXX-XXXX"

Before this can run we need to setup a few things in AppCenter, most importantly the distributionGroupId that is used in the AppCenterDistribute task.

A few more things of note: to be able to distribute the app to our designers and other testers, we need to set up a few things in AppCenter. Most importantly, we need to have a ‘distributionGroupId’ and use that in our  ‘AppCenterDistribute’ task. Next, we’ve setup a Feature Tester group that will automatically resign the app for new devices; the certificate used in the build should match the one that is used to build the app in Azure DevOps. Finally, any tester that is added to our group must set up their device once using AppCenter, after which they will be able to receive subsequential updates.

Enabling Android builds and distributions

Our Android Pipeline setup looks very similar: we have separate build tasks for testing and production, which will either produce an Android Package (APK) that is distributed using AppCenter, or an Android App Bundle (AAB) that can be released to Google Play. Our configuration looks something like this:

jobs:
  - job: Build_Android
    pool:
      vmImage: "macos-latest"
    steps:
      - task: GitVersion@5
        ...

      - task: NuGetToolInstaller@1

      - task: NuGetCommand@2
        ...

      - task: DownloadSecureFile@1
        name: "androidKeystore"
        inputs:
          secureFile: "bronkhorst.flowcontrol.keystore"
          
      - task: PowerShell@2
        displayName: Set the variable of the Android version Code
        ...

      - task: UpdateAndroidVersionManifest@1
        ...

      - task: Bash@3
        name: "BuildAndroid_aab"
        condition: eq(variables.isRelease, true)
        inputs:
          targetType: "inline"
          script: "msbuild $(Build.SourcesDirectory)/Bronkhorst.Flowcontrol.Droid/*.csproj /t:SignAndroidPackage -p:AndroidPackageFormat=aab /p:OutputPath=$(Build.ArtifactStagingDirectory) /p:Configuration=release /p:JavaSdkDirectory=$(JAVA_HOME_8_X64) -p:AndroidKeyStore=True -p:AndroidSigningKeyStore=$(androidKeystore.secureFilePath) -p:AndroidSigningStorePass=$(androidKeystore.password) -p:AndroidSigningKeyPass=$(androidKeystore.releaseKeyPassword) -p:AndroidSigningKeyAlias=release"

      - task: Bash@3
        name: "BuildAndroid_apk"
        condition: eq(variables.isRelease, false)
        inputs:
          targetType: "inline"
          script: "msbuild $(Build.SourcesDirectory)/Bronkhorst.Flowcontrol.Droid/*.csproj /t:SignAndroidPackage -p:AndroidPackageFormat=apk /p:OutputPath=$(Build.ArtifactStagingDirectory) /p:Configuration=release /p:JavaSdkDirectory=$(JAVA_HOME_8_X64) -p:AndroidKeyStore=True -p:AndroidSigningKeyStore=$(androidKeystore.secureFilePath) -p:AndroidSigningStorePass=$(androidKeystore.password) -p:AndroidSigningKeyPass=$(androidKeystore.releaseKeyPassword) -p:AndroidSigningKeyAlias=release"

      - task: PublishBuildArtifacts@1
        inputs:
          PathtoPublish: "$(Build.ArtifactStagingDirectory)"
          ArtifactName: "$(androidArtifactName)"
          publishLocation: "Container"

      - task: AppCenterDistribute@3
        displayName: Android group release
        condition: eq(variables.isRelease, false)
        inputs:
          serverEndpoint: "BronkhorstAppCenterAndroid"
          appSlug: "Luminis/Bornkhorst.FlowControl.App.Android"
          appFile: "$(Build.ArtifactStagingDirectory)/com.bronkhorst.flowcontrol-Signed.apk"
          buildVersion: "$(GitVersion.FULLSEMVER)"
          symbolsOption: "Android"
          releaseNotesOption: "input"
          releaseNotesInput: "$(GITVERSION.FULLSEMVER)"
          destinationType: "groups"
          distributionGroupId: "be0bc1dc-26d2-40c4-90e9-c8ac1fdfae05"

After building the APK we can publish it to the AppCenter Test group.

We once again use an AppCenter Test group to onboard our designers and other reviewers. Unlike iOS, signing of the app is not needed, as long as our reviewers allow installations from unknown sources. The AppCenter Android app can be used to easily install new versions.

Enabling Windows builds and distributions

In our case, we also release a Windows application. The process is once again very similar. To prevent unnecessary wait times, we opted to only test the x86 release version of our app, reducing our build time by half compared to building for more targets. Our setup, roughly:

jobs:
  - job: Build_UWP
    pool:
      vmImage: "windows-latest"
    steps:
      - task: GitVersion@5
        ...

      - task: NuGetToolInstaller@1

      - task: NuGetCommand@2
        ...

      - task: DownloadSecureFile@1
        name: "uwpCert"
        inputs:
          secureFile: "Bronkhorst.FlowControl.UWP.pfx"

      - task: PowerShell@2
        displayName: "Set Package.appxmanifest version to GitVersion"
        ...

      - task: VSBuild@1
        condition: eq(variables.isRelease, false)
        name: "BuildReleaseTestVersion"
        displayName: "Build Release Test Version"
        inputs:
          platform: "x86"
          solution: "$(Build.SourcesDirectory)/Bronkhorst.Flowcontrol.UWP/*.csproj"
          configuration: "Release"
          msbuildArgs: '/p:AppxBundlePlatforms="x86"
                        /p:AppxPackageDir="$(Build.ArtifactStagingDirectory)"
                        /p:AppxBundle=Always
                        /p:UapAppxPackageBuildMode=StoreUpload
                        /p:AppxPackageSigningEnabled=true
                        /p:PackageCertificateThumbprint=""
                        /p:PackageCertificateKeyFile="$(uwpCert.secureFilePath)"
                        /p:PackageCertificatePassword="$(uwpCert.password)"'

      - task: VSBuild@1
        condition: eq(variables.isRelease, true)
        name: "BuildReleaseVersion"
        displayName: "Build Release Version"
        inputs:
          platform: "x86"
          solution: "$(Build.SourcesDirectory)/Bronkhorst.Flowcontrol.UWP/*.csproj"
          configuration: "Release"
          msbuildArgs: '/p:AppxBundlePlatforms="x86|x64|ARM"
                        /p:AppxPackageDir="$(Build.ArtifactStagingDirectory)"
                        /p:AppxBundle=Always
                        /p:UapAppxPackageBuildMode=StoreUpload
                        /p:AppxPackageSigningEnabled=true
                        /p:PackageCertificateThumbprint=""
                        /p:PackageCertificateKeyFile="$(uwpCert.secureFilePath)"
                        /p:PackageCertificatePassword="$(uwpCert.password)"'

      - task: CopyFiles@2
        displayName: "Copy Output Files to: $(Build.ArtifactStagingDirectory)"
        inputs:
          SourceFolder: "$(system.defaultworkingdirectory)"
          Contents: '**\bin\Release\**'
          TargetFolder: "$(Build.ArtifactStagingDirectory)"

      - task: PublishBuildArtifacts@1
        inputs:
          PathtoPublish: "$(Build.ArtifactStagingDirectory)"
          ArtifactName: "$(uwpArtifactName)"
          publishLocation: "Container"

      - task: AppCenterDistribute@3
        displayName: "UWP group release $(GitVersion.AssemblySemVer)"
        condition: eq(variables.isRelease, false)
        inputs:
          serverEndpoint: "BronkhorstAppCenterUWP"
          appSlug: "Luminis/Bronkhorst-FlowSuite-UWP"
          appFile: "$(Build.ArtifactStagingDirectory)/Bronkhorst.FlowControl.UWP_$(GitVersion.AssemblySemVer)_Test/Bronkhorst.FlowControl.UWP_$(GitVersion.AssemblySemVer)_x86.msixbundle"
          buildVersion: "$(GITVERSION.AssemblySemVer)"
          symbolsOption: "UWP"
          releaseNotesOption: "input"
          releaseNotesInput: "$(GITVERSION.FULLSEMVER)"
          destinationType: "groups"
          distributionGroupId: "XXXX-XXXX-XXXX-XXXX-XXXX"

Unsigned app bundles cannot be installed on Windows, so we need to sign our app using a certificate. If you use one that is not trusted by Windows, your reviews must first install the certificate as a root certificate before they can install the test application versions.

Conclusion

I hope that Mike and I have managed to convince you that improving user experience and project flow is possible with the right attitude, process, and technology. In our case, Azure DevOps and AppCenter helped us a great deal in reducing lead times and thus delivering customer value early and often. Our setup is specific, but the principles of our solutions should be easily applicable to other technology stacks and similar use cases. We’d love to hear how you did it!