Enable CI/CD for Windows apps with GitHub Actions and Azure Static Web Apps
Published Jun 10 2020 07:59 AM 6,352 Views
Microsoft

Azure Static Web Apps is a new Azure service launched in Preview at Build 2020, which offers an app service dedicated to static websites. You could say "why is it so special"? In the end, hosting a static website is relatively simple, since there isn't any server-side component: no databases, no ASP.NET Core or PHP or Node runtime, just pure HTML, CSS and JavaScript. Azure Static Web Apps is more than just hosting for static content: thanks to its tight connection to GitHub, it supports creating automated pipelines (through GitHub Actions) which are able to automatically build and deploy full stack web apps.

 

This service is a great companion for frameworks like Gatsby or Hugo, which are able to generate static pages as part of the build process. Let's say that you're using Hugo to host your personal blog. Thanks to Azure Static Web App, you can just simply commit to your GitHub repository a markdown file with your latest post to trigger the execution of a GitHub Action, which will build it as a static page.

And if you need to add a server-side component (for example, hosting a REST API), the Azure Static Web App service supports the deployment of serverless APIs based on Azure Functions.

 

However, as you know, in my everyday job I'm focused on the development and the deployment of Windows desktop applications. And, as you probably know if you follow this blog or if you have read my book about MSIX, I'm a big fan of MSIX and the App Installer technology, which enables to easily have a solid CI/CD story for Windows applications. Thanks to them, in fact, we can enable features like automatic updates, critical updates, differential updates, etc. And guess what? All you need is a static website that can host your MSIX package, plus the AppInstaller file.

 

I guess now you know where I'm headed to =) Let's see how we can use an Azure Static Web App to host our MSIX package, which will be automatically built and deployed by a GitHub Action.

 

Set up the Azure Static Web App

As first step, let's create our Azure Static Web App. Login to the Azure portal with your account and click on Create a resource. Start a search using the static keyword. One of the results will be Static Web App (Preview), as in the following image:

 

NewStaticWebApp.png

 

Click on it and choose Create to start the process:

 

CreateNewApp.png

 

The first information you have to provide are the subscription, the resource group, the name of the app and the region. The only available SKU, for the moment, is the Free one, since the service is in preview. Currently this service works only with GitHub, since it doesn't use it just to connect to the repository, but it's going to create the GitHub Action needed to compile and deploy the project for us. As such, the next required step is to click on Sign in with GitHub to complete the login process with your GitHub account. Once you are logged in, you will have the opportunity to choose an organization, a repository and a branch from the ones have on GitHub:

 

GitHubSetup.png

 

For our scenario we're going to choose a repository which contains a .NET Desktop application packaged with the Windows Application Packaging Project. The one I'm using for this blog post is available at https://github.com/qmatteoq/ContosoApp. It's just a plain WPF application based on .NET Core 3.1.

Once you have completed the configuration, press Review + create, followed by Create in the review page. Once the deployment has been completed, you will notice a few changes in your GitHub repository:

 

  • There will be a new folder, called github/workflows with a YAML file inside. That's our GitHub workflow. We can see it also by clicking on the Actions tab in the repository:

GitHubAction.png

The workflow is called Azure Static Web Apps CI/CD and it will be executed immediately. However, it will fail, since our repository contains a desktop application and the default workflow isn't tailored for this scenario. Don't worry, we're going to fix that!

 

  • If you go to the Settings section of the repository and you move to the Secrets tab, you will find out that Azure Static Web App has added a new secret for you:

Secret.png

This secret contains the API token which is required by the GitHub Action to connect to the Azure Static Web App instance we have created, in order to perform the deployment.

 

Customize the workflow

Let's take a look at the workflow that the Azure Static Web App has created for us:

 

name: Azure Static Web Apps CI/CD

on:
  push:
    branches:
      - master
  pull_request:
    types: [opened, synchronize, reopened, closed]
    branches:
      - master

jobs:
  build_and_deploy_job:
    if: github.event_name == 'push' || (github.event_name == 'pull_request' && github.event.action != 'closed')
    runs-on: ubuntu-latest
    name: Build and Deploy Job
    steps:
      - uses: actions/checkout@v2
        with:
          submodules: true
      - name: Build And Deploy
        id: builddeploy
        uses: Azure/static-web-apps-deploy@v0.0.1-preview
        with:
          azure_static_web_apps_api_token: ${{ secrets.AZURE_STATIC_WEB_APPS_API_TOKEN_CALM_DESERT_05F99C503 }}
          repo_token: ${{ secrets.GITHUB_TOKEN }} # Used for Github integrations (i.e. PR comments)
          action: "upload"
          ###### Repository/Build Configurations - These values can be configured to match you app requirements. ######
          # For more information regarding Static Web App workflow configurations, please visit: https://aka.ms/swaworkflowconfig
          app_location: "/" # App source code path
          api_location: "api" # Api source code path - optional
          app_artifact_location: "" # Built app content directory - optional
          ###### End of Repository/Build Configurations ######

  close_pull_request_job:
    if: github.event_name == 'pull_request' && github.event.action == 'closed'
    runs-on: ubuntu-latest
    name: Close Pull Request Job
    steps:
      - name: Close Pull Request
        id: closepullrequest
        uses: Azure/static-web-apps-deploy@v0.0.1-preview
        with:
          azure_static_web_apps_api_token: ${{ secrets.AZURE_STATIC_WEB_APPS_API_TOKEN_CALM_DESERT_05F99C503 }}
          action: "close"

The workflow contains two distinct jobs:

  • build_and_deploy_job is triggered whenever you submit a new Pull Request to the repository. The goal of this job is to build the updated website included in the Pull Request and to deploy it in a staging environment, so that you can test that everything is working as expected.
  • close_pull_request_job is triggered whenever you close a Pull Request. This means that the you have validated that the Pull Request is good and, as such, the staging environment can be deleted because the new version of the website can be safely pushed to production.

As you can imagine, however, for our scenario this isn't really applicable, since we aren't really deploying a website, but a desktop application to be deployed. As such, we're going to basically delete everything from this workflow file, except for the Build and deploy task, which is based on the action called Azure/static-web-apps-deploy@v0.0.1-preview. We're going to use this one to publish the output of the Visual Studio compilation: the MSIX package, the AppInstaller file and the web page to trigger the installation.

 

To customize the workflow, we're going to take inspiration from the template created by .NET Team, which is a great starting point when you want to build desktop .NET Core applications on GitHub. This template perfectly fits my scenario: I have a WPF application based on .NET Core and a Windows Application Packaging Project to generate the MSIX package.

 

Let's start by creating a copy of the existing workflow file. We're going to need it later. Now open on GitHub the workflow file (in my case, it's https://github.com/qmatteoq/ContosoApp/blob/master/.github/workflows/azure-static-web-apps-calm-dese...) and click on the edit icon (the small pencil in the toolbar):

 

EditFile.png

 

When you do this, GitHub will propose you a special edit interface tailored for workflows. Thanks a to a panel on the right, you'll be able to easily browse the Actions marketplace and integrate tasks in the workflow.

 

EditGitHubAction.png

 

For the moment, let's delete everything (except for the first line which defines the name) and replace the content with the one from the .NET Desktop workflow. This is how your workflow should look like:

 

name: Azure Static Web Apps CI/CD

on:
  push:
    branches: [ master ]
  pull_request:
    branches: [ master ]

jobs:

  build:

    strategy:
      matrix:
        configuration: [Debug, Release]

    runs-on: windows-latest  # For a list of available runner types, refer to 
                             # https://help.github.com/en/actions/reference/workflow-syntax-for-github-actions#jobsjob_idruns-on

    env:
      Solution_Name: your-solution-name                         # Replace with your solution name, i.e. MyWpfApp.sln.
      Test_Project_Path: your-test-project-path                 # Replace with the path to your test project, i.e. MyWpfApp.Tests\MyWpfApp.Tests.csproj.
      Wap_Project_Directory: your-wap-project-directory-name    # Replace with the Wap project directory relative to the solution, i.e. MyWpfApp.Package.
      Wap_Project_Path: your-wap-project-path                   # Replace with the path to your Wap project, i.e. MyWpf.App.Package\MyWpfApp.Package.wapproj.

    steps:
    - name: Checkout
      uses: actions/checkout@v2
      with:
        fetch-depth: 0

    # Install the .NET Core workload
    - name: Install .NET Core
      uses: actions/setup-dotnet@v1
      with:
        dotnet-version: 3.1.101

    # Add  MSBuild to the PATH: https://github.com/microsoft/setup-msbuild
    - name: Setup MSBuild.exe
      uses: microsoft/setup-msbuild@2008f912f56e61277eefaac6d1888b750582aa16

    # Execute all unit tests in the solution
    - name: Execute unit tests
      run: dotnet test

    # Restore the application to populate the obj folder with RuntimeIdentifiers
    - name: Restore the application
      run: msbuild $env:Solution_Name /t:Restore /p:Configuration=$env:Configuration
      env:
        Configuration: ${{ matrix.configuration }}

    # Decode the base 64 encoded pfx and save the Signing_Certificate
    - name: Decode the pfx
      run: |
        $pfx_cert_byte = [System.Convert]::FromBase64String("${{ secrets.Base64_Encoded_Pfx }}")
        $certificatePath = Join-Path -Path $env:Wap_Project_Directory -ChildPath GitHubActionsWorkflow.pfx
        [IO.File]::WriteAllBytes("$certificatePath", $pfx_cert_byte)

    # Create the app package by building and packaging the Windows Application Packaging project
    - name: Create the app package
      run: msbuild $env:Wap_Project_Path /p:Configuration=$env:Configuration /p:UapAppxPackageBuildMode=$env:Appx_Package_Build_Mode /p:AppxBundle=$env:Appx_Bundle /p:PackageCertificateKeyFile=GitHubActionsWorkflow.pfx /p:PackageCertificatePassword=${{ secrets.Pfx_Key }}
      env:
        Appx_Bundle: Always
        Appx_Bundle_Platforms: x86|x64
        Appx_Package_Build_Mode: StoreUpload
        Configuration: ${{ matrix.configuration }}

    # Remove the pfx
    - name: Remove the pfx
      run: Remove-Item -path $env:Wap_Project_Directory\$env:Signing_Certificate

    # Upload the MSIX package: https://github.com/marketplace/actions/upload-artifact
    - name: Upload build artifacts
      uses: actions/upload-artifact@v1
      with:
        name: MSIX Package
        path: ${{ env.Wap_Project_Directory }}\AppPackages

Let's go and customize a few things. First, you'll need to customize the environment variables defined at the top to point to your solutions and project. You'll have to setup:

 

  • Solution_Name with the relative path of the solution file
  • Wap_Project_Directory with the relative path of the folder which contains the Windows Application Packaging Project
  • Wap_Project_Path with the full path of Windows Application Packaging Project file

Optionally, you can also use the Test_Project_Path to configure the path of the project which contains your unit tests. In my case, since it's a sample project, I don't have one, so I simply removed that variable. This is how my environment configuration looks like:

 

env:
  Solution_Name: ContosoApp.sln                
  Wap_Project_Directory: ContosoApp.Package    
  Wap_Project_Path: ContosoApp.Package\ContosoApp.Package.wapproj

Second, for our scenario, we don't really need to build the application both in Debug and Release mode. We just want to have, as output, a single release MSIX package, so that we can deploy it on our website. As such, remove from the configuration matrix the Debug entry:

 

strategy:
  matrix:
    configuration: [Release]

As next step, I'm going to customize some of the available tasks:

  • Execute unit tests isn't needed for my sample application, since I don't have unit tests. As such, I'm going to delete this one.

  • Build. Since we're going to use the AppInstaller technology to distribute the package using our Azure Static Web App, we need to tweak a bit the various parameters.

    • The first one is the Appx_Package_Build_Mode parameter, which by default is set to StoreUpload, which is the needed when you want to publish the application on the Microsoft Store. In this case we're using manual distribution, so we need to change it to SideloadOnly.
    • We need to add the /p:GenerateAppInstallerFile parameter and set it to true, in order to generate the .appinstaller file and the web page as part of the process.
    • We need to add the /p:AppInstallerUri parameter and set it to the URL that has been assigned to our Azure Static Web App. You can find this URL in the Azure portal:

AzureAppUrl.png

 

This is how the task looks like after the changes:

 

# Create the app package by building and packaging the Windows Application Packaging project
- name: Create the app package
  run: msbuild $env:Wap_Project_Path /p:Configuration=$env:Configuration /p:UapAppxPackageBuildMode=$env:Appx_Package_Build_Mode /p:AppxBundle=$env:Appx_Bundle /p:GenerateAppInstallerFile=$env:Generate_AppInstaller /p:AppInstallerUri=$env:AppInstaller_Url /p:PackageCertificateKeyFile=GitHubActionsWorkflow.pfx /p:PackageCertificatePassword=${{ secrets.Pfx_Key }}
  env:
    Appx_Bundle: Always
    Appx_Bundle_Platforms: x86|x64
    Appx_Package_Build_Mode: SideloadOnly
    Configuration: ${{ matrix.configuration }}
    Generate_AppInstaller: true
    AppInstaller_Url: https://calm-desert-05f99c503.azurestaticapps.net

Setting up the signing

As I have discussed multiple times in this blog, signing is a critical aspect of MSIX packaging. If you don't sign a MSIX package with a trusted certificate, the user will never be able to install it. At the same time, you must be very careful in how you enable signing as part of a CI/CD pipeline. If you expose your private certificate, someone might be able to steal it and use your identity to sign malicious applications. There are multiple approaches to sign MSIX packages in the right way as part of a CI/CD pipeline. The approach used by the workflow template is to ask to the developer to encode the PFX into a base64 string and store it as a secret. Then the workflow includes a PowerShell script which takes care of decoding the secret back into a PFX, so that it can be used as part of the Visual Studio build to do the signing.

 

The main reasoning of using this approach on GitHub is that, unlike Azure DevOps, the platform doesn't have the concept of Secure Files, which is often used in scenarios like this one.

The GitHub Action workflow already contains everything you need to sign the package. The only missing step is to create the secrets to store the various required information.

 

  1. Open a PowerShell terminal in the folder in which you keep your PFX certificate.

  2. Run the following script:

    $pfx_cert = Get-Content '.\SigningCertificate.pfx' -Encoding Byte
    [System.Convert]::ToBase64String($pfx_cert) | Out-File 'SigningCertificate_Encoded.txt'
    
  3. At the end of the process you will find, in the same folder, a text file called SigningCertificate_Encoded.txt, which contains the certificate encoded as a base64 string. Open the text file and copy the whole content.

  4. Now go to your repository on GitHub, choose Settings → Secrets and create a new secret.

  5. Call the secret Base64_Encoded_Pfx and, as value, paste the encoded base64 string you have just copied.

  6. Now create a new secret and call it Pfx_Key. As value, type the password for the signing certificate.

That's it. Another and safer alternative to this approach is to leverage Azure Key Vault to store your certificate and the Azure SignTool utility to do the actual signing as part of the pipeline. You can find all the details about this approach, including how to use it in a GitHub Action workflow, in a recent blog post I wrote.

 

Reviewing the pipeline

We have finished the work on the build pipeline. This is how it should look like after the changes:

 

name: Azure Static Web Apps CI/CD

on:
  push:
    branches: [ master ]
  pull_request:
    branches: [ master ]

jobs:

  build:

    strategy:
      matrix:
        configuration: [Release]

    runs-on: windows-latest  # For a list of available runner types, refer to 
                             # https://help.github.com/en/actions/reference/workflow-syntax-for-github-actions#jobsjob_idruns-on

    env:
      Solution_Name: ContosoApp.sln                
      Wap_Project_Directory: ContosoApp.Package    
      Wap_Project_Path: ContosoApp.Package\ContosoApp.Package.wapproj                  

    steps:
    - name: Checkout
      uses: actions/checkout@v2
      with:
        fetch-depth: 0

    # Add  MSBuild to the PATH: https://github.com/microsoft/setup-msbuild
    - name: Setup MSBuild.exe
      uses: microsoft/setup-msbuild@2008f912f56e61277eefaac6d1888b750582aa16

    # Restore the application to populate the obj folder with RuntimeIdentifiers
    - name: Restore the application
      run: msbuild $env:Solution_Name /t:Restore /p:Configuration=$env:Configuration
      env:
        Configuration: ${{ matrix.configuration }}
        
    # Decode the base 64 encoded pfx and save the Signing_Certificate
    - name: Decode the pfx
      run: |
        $pfx_cert_byte = [System.Convert]::FromBase64String("${{ secrets.Base64_Encoded_Pfx }}")
        $certificatePath = Join-Path -Path $env:Wap_Project_Directory -ChildPath GitHubActionsWorkflow.pfx
        [IO.File]::WriteAllBytes("$certificatePath", $pfx_cert_byte)
  
    # Create the app package by building and packaging the Windows Application Packaging project
    - name: Create the app package
      run: msbuild $env:Wap_Project_Path /p:Configuration=$env:Configuration /p:UapAppxPackageBuildMode=$env:Appx_Package_Build_Mode /p:AppxBundle=$env:Appx_Bundle /p:GenerateAppInstallerFile=$env:Generate_AppInstaller /p:AppInstallerUri=$env:AppInstaller_Url /p:PackageCertificateKeyFile=GitHubActionsWorkflow.pfx /p:PackageCertificatePassword=${{ secrets.Pfx_Key }}
      env:
        Appx_Bundle: Always
        Appx_Bundle_Platforms: x86|x64
        Appx_Package_Build_Mode: SideloadOnly
        Configuration: ${{ matrix.configuration }}
        Generate_AppInstaller: true
        AppInstaller_Url: https://calm-desert-05f99c503.azurestaticapps.net
            
    # Remove the pfx
    - name: Remove the pfx
      run: Remove-Item -path $env:Wap_Project_Directory\GitHubActionsWorkflow.pfx

    # Upload the MSIX package: https://github.com/marketplace/actions/upload-artifact
    - name: Upload build artifacts
      uses: actions/upload-artifact@v1
      with:
        name: MSIX Package
        path: ${{ env.Wap_Project_Directory }}\AppPackages

This is enough to get a ready to be deployed MSIX package. If you commit any change to the repository which hosts your desktop app, the process should complete without errors and, in the end, you should have an artifact folder which contains:

 

  • Your signed MSIX package
  • The file with .appinstaller extension required to install the package from a website or network share
  • A web page (index.html) which contains the link to trigger the installation of the MSIX package

Deploy on Azure Static Web App

Now it's time to go back to the original workflow file that was created when we have created the Azure Static Web App and that we have copied before starting making changes. We need, in fact, to leverage the task that will take care of deploying on the Web App the artifact we have just created. However, in order to do this, we're going to create a different job. Its purpose will be to download the artifact we have generated and to deploy it.

 

Why using a separate job? The first reason is more "philosophical". The deployment is a different step compared to the build process. Azure DevOps does a great job in helping to keep the two phases separated, by providing release pipelines to handle the release management story. GitHub doesn't support this approach but, still, a multi-stage pipeline can help us to better split the two phases. The second reason is more practical. The action which performs the deployment to Azure Static Web App (the Azure/static-web-apps-deploy@v0.0.1-preview one) works only on Linux, while the build process we have executed so far must run on Windows. By using multiple jobs, we can use different environments: the build job will continue to run on a Windows hosted agent, while we're going to run the deployment job on a Linux machine.

 

Let's go and add the second job after the first one we have previously configured:

 

deploy:
  needs: [build]
  runs-on: ubuntu-latest
  name: Deploy Job
  steps:
    - name: Download Package artifact
      uses: actions/download-artifact@master
      with:
        name: MSIX Package
        path: MSIX 

    - name: Build And Deploy
      id: builddeploy
      uses: Azure/static-web-apps-deploy@v0.0.1-preview
      with:
        azure_static_web_apps_api_token: ${{ secrets.AZURE_STATIC_WEB_APPS_API_TOKEN_CALM_DESERT_05F99C503 }}
        repo_token: ${{ secrets.GITHUB_TOKEN }} # Used for Github integrations (i.e. PR comments)
        action: "upload"
        ###### Repository/Build Configurations - These values can be configured to match you app requirements. ######
        # For more information regarding Static Web App workflow configurations, please visit: https://aka.ms/swaworkflowconfig
        app_location: "MSIX" # App source code path
        ###### End of Repository/Build Configurations ######

This job contains only two tasks:

 

  • The first one uses the action actions/download-artifact to download on the machine the artifact we have just created in the build job. The name property must match the value of the name property we have set in the actions/upload-artifact task of the build job.

  • The second one is the task we have previously copied from the original workflow file, which takes care to do the deployment to the Azure Static Web App. Compared to the original task, we have to make a couple of changes:

    • The app_location parameter must be set with the name of the folder that contains the artifact we need to deploy. In our case, it's MSIX, which is the value we have set as path in the actions/download-artifact task.
    • We can remove the api_location and app_artifact_location properties, since we aren't deploying a full static web app.

As you can notice, we have configured the job to run on Linux (runs-on: ubuntu-latest) and we have used the needs parameter to specify that this task must run only after the build one has been completed. Without this option, GitHub would try to run both jobs in parallel.

 

That's it! Now try to commit any change to the code to trigger the execution of the workflow. If you did everything in the correct way, once the workflow is completed, you should be able to open your browser on the URL assigned to your Azure Static Web App and see the web page generated by Visual Studio to trigger the installation:

 

ContosoApp.png

 

By clicking on the Get the app button, you will trigger the process to start the installation:

 

ContosoAppInstallation.png

 

Wrapping up

In this blog post we have seen how to use the recently announced Azure Static Web App service to host our MSIX packages and enable an easy installation process of our desktop apps through a website. By tweaking the project we can also enable automatic updates: we just need to configure the AppInstaller file to support automatic updates and use a tool like Nerdbank.GitVersioning to handle the versioning of the MSIX packages, so that we're sure that every new generated package will have a higher version number.

 

The sample project used in this blog post (including the GitHub workflow) is available here. If, instead, you want to see a more advanced approach, you can take a look at another project from me. This second repository contains a more complete workflow, which includes versioning management and signing using Azure Key Vault.

 

A special thanks to Rafael Rivera and Mitchell Webster for the help in troubleshooting and fixing a series of issues that were preventing Azure Static Web Apps to work properly with MSIX deployment.

 

Happy deployment!

3 Comments
Version history
Last update:
‎Jun 10 2020 07:59 AM
Updated by: