October 15th, 2020

Static Web App PR Workflow for Azure App Service Using Azure DevOps

Abel
Principal Cloud Advocate, DevOps Lead

Static Web App PR Workflow for Azure App Service using Azure DevOps

Microsoft recently announced a new Azure service called Static Web Apps. Static Web Apps are tailored for apps with their code in GitHub with static content (html/javascript/css) and backend api’s.

This blog post is not about Static Web Apps. It’s about the amazing pull request workflow that you get right out of the box with Static Web Apps.

Static Web Apps use GitHub Actions for CI/CD and when a user issues a pull request the following happens:

  • The app is built/packaged
  • A staging environment is dynamically provisioned
  • The app with the PR code is deployed into the newly provisioned staging environment
  • A comment is added to the pull request with the URL to the newly provisioned staging environment

When the pull request is closed

  • The provisioned staging environment for this pull request is deleted

Ok, HOW COOL IS THAT!!!!

That’s great for Static Web Apps, but I have a bunch of apps in Azure App Service. The code is in Azure Repos and the CI/CD pipeline uses Azure Pipelines. I want that Static Web App pull request workflow for my apps in Azure App Service.

Luckily it wasn’t hard to do. In this blog post, I’ll walk you through how I did it. First I’ll show everything using the classic editor as that looks better than a wall of yml. And at the end, I’ll share the yml for my pipeline.

Making this PR workflow for Azure App Service, Azure Repos and Azure Pipelines

For this post, I’ll use a repo that holds a .NET Core web app that is hosted in Azure App Service. Currently my CI build looks like this:

Initial CI Pipeline

Nothing magic about it. This is just your standard .NET Core build. Any time code is checked into the main branch, this build kicks off.

Enabling Build For Every Pull Request

I need this build to kick off any time someone issues a pull request. This can be set in the branch policies for this repository. This doc will walk you through the steps to do this.

After following the above steps, every time a pull request is issued, my CI build is triggered.

Adding Steps for a Pull Request

If all the build tasks are successful and if the build trigger is because of a pull request, I want the following to happen:

  • Provision a staging environment
  • Deploy my app to the staging environment
  • Write a comment to the pull request with the url of the newly provisioned slot.

Because I’m using Azure App Service, I can totally use deployment slots for the staging environments. Since there are no tasks that let me dynamically create slots, I’ll just use the Azure CLI to dynamically create my slot. Once it is created, I can use a normal App Service deploy task to deploy my app to the slot. And to write a comment to the pull request, I’ll use the Azure DevOps REST API.

Creating a Task to Provision the Staging Slot

To provision a new staging slot, I’ll use the Bash Script task and from there, use the Azure CLI to provision my slot.

I also want this task to run only if all the previous tasks were successful and the reason for this build is a pull request. To do this, I drag a Bash Script Task to the end and I set the Control Option > Run this task to

Custom conditions

and I set Control Option > Custom condition to

and(succeeded(), eq(variables['Build.Reason'], 'PullRequest'))

Control Options Settings

Next, let’s give this task a better name

Create CI Staging Slot For PR

and make sure Inline is selected

Inline Bash Task

In the Script text box, that’s where I will drop in my bash script which calls the Azure CLI to provision the new slot.

To do that, I will need some pipeline variables. First, to use the Azure CLI, I will need to login using an Azure Service Principal. Follow the link to create a service principal. Next, to create a slot, I will need the app service name as well as the resource group the app service is in.

I add these variables to the variable section of my pipeline. For my service principal password, I encrypt it using the lock function. I usually store all my secrets in key vault, but to simplify this example, I’ll just dump all my variables in the variable section of the pipeline.

VariablesInPipeline

Finally, for the script to provisions a new deployment slot, I need to do the following:

Because I can have multiple pull request happening at the same time and each slot must have a unique slot name, I’ll use the pull request ID as part of my slot name. I’ll name my slot

staging-pr-$(System.PullRequest.PullRequestId)

System.PullRequest.PullRequestId is a built in pipeline variable

In the Script text field, I add the following script:

   # Login to Azure using service principal
   echo 'Logging into Azure with service principal...'
   az login \
     --service-principal \
     --username $(servicePrincipal.name) \
     --tenant $(servicePrincipal.tenant) \
     --password $(servicePrincipal.password)
   echo 'Logged into Azure'
   echo ''

   # Create staging slot for PR
   echo 'Creating staging slot for PR...'
   az webapp deployment slot create \
       --name $(webApp.name) \
       --resource-group $(webApp.resourceGroup) \
       --slot staging-pr-$(System.PullRequest.PullRequestId)
   echo "Created staging slot for PR " $(System.PullRequest.PullRequestId)

Create Staging Slot

Deploy App To The Newly Provisioned Slot

To deploy my app to the slot, I just use a standard App Service deploy task. Like the previous task, I only want this task to run if all my previous tasks were successful and this build is triggered by a pull request. So I set Control Option > Run this task to

Custom conditions

and I set Control Option > Custom condition to

and(succeeded(), eq(variables['Build.Reason'], 'PullRequest'))

Control Options For Deploy Task

Next, I give this task a nicer name and set the parameters so I’m deploying to the slot. Everything here is pretty standard except the slot name is dynamic based off of the pull request ID. So for the Slot text field, I put in

staging-pr-$(System.PullRequest.PullRequestId)

Slot Name In Deploy Task

Write the URL of the Staging Slot as a PR Comment

To write the URL of the staging slot as a pull request comment, I’ll again use a bash script task to call the Azure DevOps REST API. This means I’ll need to use an Azure DevOps PAT. I create a PAT using this, and then I stick it into the variables section of the pipeline and encrypt it.

Pipeline Variable Section For Bash Task To Add Message to AzD

Next, I drag a bash task to the end and again, this task should only run if all the previous tasks were successful and the build was triggered by a pull request. So I set Control Option > Run this task to

Custom conditions

and I set Control Option > Custom condition to

and(succeeded(), eq(variables['Build.Reason'], 'PullRequest'))

Control Option For Messaging Task

Next, I give this task a better name

Add staging URL to PR Comment

select

Inline

and in the Script textbox, I add the bash script which uses the Azure DevOps Rest API to add the comment to the pull request.

# Add PR comment with link to staging
echo "Adding PR comment with link to staging environment..."
message="Created staging environment for PR - https://$(webApp.name)-staging-pr-"$(System.PullRequest.PullRequestId)".azurewebsites.net/"
echo "message: $message"

# post message to PR using azure devops rest api
curl -i --user :$(azureDevOpsPAT) -H "Content-Type: application/json" -H "Accept: aon" -X POST -d '{"comments": [{ "parentCommentId":0, "content": "'"$message"'", "commentType": 1}], "status": 1}' $(System.TeamFoundationCollectionUri)/$(System.TeamProject)/_apis/git/repositories/$(Build.Repository.ID)/pullRequests/$(System.PullRequest.PullRequestId)/threads?api-version=6.1-preview.1

echo "Done adding PR comment with link to staging environment"

Inline Bash Task For Messaging

Add Step For When PR Closes

I need to add one final step. When the pull request is closed and the code is merged to master, I need to clean up my staging environment and delete the slot for the specific pull request.

Azure DevOps doesn’t have any easy triggers in the pipeline for when you close a pull request. You could do this with Azure DevOps Web Hooks. But this would require me to use something like Azure Functions to do the pull request cleanup/delete slot logic. I really wanted to keep everything within my pipelines so I’m just triggering this task based on the version control merge message. Whenever a pull request is merged, the merge message will start with Merged PR xxx where xxx is the pull request ID.

I drag a bash task onto the board and I only want this task to run if all the previous tasks were successful and if the commit message starts wtih Merged PR. So I set Control Option > Run this task to

Custom conditions

and I set Control Option > Custom condition to

and(succeeded(), startsWith(variables['Build.SourceVersionMessage'], 'Merged PR '))

Control Option For Cleanup Task

I give this task a better name, make sure inline is selected and in the Script text box, I add the bash script which uses the Azure CLI to delete the right staging slot.

# PR Closed, changes merged to main
echo 'PR close, changes merged to main'

# get the source version message
sourceVersionMessage="$(Build.SourceVersionMessage)"
echo 'build source version message: ' $sourceVersionMessage

# parse the PR number from the source version message
prNumber=$(echo $sourceVersionMessage| sed -r 's/^([^.]+).*$/\1/; s/^[^0-9]*([0-9]+).*$/\1/')
echo 'prNumber: ' $prNumber

# Login to Azure using service principal
echo 'Logging into Azure with service principal...'
az login \
  --service-principal \
  --username $(servicePrincipal.name) \
  --tenant $(servicePrincipal.tenant) \
  --password $SERVICE_PRINCIPAL_PASSWORD
echo 'Logged into Azure'
echo ''

# Delete PR staging slot
echo 'Deleting staging slot for PR ' $prNumber '...'
az webapp deployment slot delete \
  --name $(webApp.name) \
  --resource-group $(webApp.resourceGroup) \
  --slot staging-pr-$prNumber
echo 'Deleted slot for PR ' $prNumber

Complete CI Pipeline

Now, my CI build does everything the Static Web App pull request workflow does and it looks like this:

Complete CI Workflow

What about YML pipelines?

Yes, you can do all this using YML pipelines. In fact, that’s how I would normally do this. However, that classic editor sure looks nicer than a wall of yml so I figured I’d start with the classic editor. Here is my YML

trigger:
- master

pool:
  vmImage: 'ubuntu-latest'

variables:
  servicePrincipal.name: 'http://AbelDeployDemoBackupPrincipal'
  servicePrincipal.tenant: '72f988bf-86f1-41af-91ab-2d7cd011db47'
  webApp.name: 'PRWorkflowBlogAppService'
  webApp.resourceGroup: 'PRWorkflowBlog'
  BuildConfiguration: 'release'

steps:

# Use .NET core 5 sdk
- task: UseDotNet@2
  displayName: 'Use .NET Core 5 SDK'
  inputs:
    version: 5.x
    includePreviewVersions: true

# Build .net core project    
- task: DotNetCoreCLI@2
  displayName: Build
  inputs:
    projects: '$(Parameters.RestoreBuildProjects)'
    arguments: '--configuration $(BuildConfiguration)'

# Restore project
- task: DotNetCoreCLI@2
  displayName: Build
  inputs:
    projects: '$(Parameters.RestoreBuildProjects)'
    arguments: '--configuration $(BuildConfiguration)'

# Run unit tests
- task: DotNetCoreCLI@2
  displayName: Test
  inputs:
    command: test
    projects: '$(Parameters.TestProjects)'
    arguments: '--configuration $(BuildConfiguration)'

# Publish .net core app to staging directory
- task: DotNetCoreCLI@2
  displayName: Publish
  inputs:
    command: publish
    publishWebProjects: True
    arguments: '-r win-x64 --self-contained true --configuration $(BuildConfiguration) --output $(build.artifactstagingdirectory)'

# Publish build bits as build artifact
- task: PublishBuildArtifacts@1
  displayName: 'Publish Artifact'
  inputs:
    PathtoPublish: '$(build.artifactstagingdirectory)'
  condition: succeededOrFailed()

# Create CI Staging Slot for PR
- bash: |
   # Login to Azure using service principal
   echo 'Logging into Azure with service principal...'
   az login \
     --service-principal \
     --username $(servicePrincipal.name) \
     --tenant $(servicePrincipal.tenant) \
     --password $SERVICE_PRINCIPAL_PASSWORD
   echo 'Logged into Azure'
   echo ''

   # Create staging slot for PR
   echo 'Creating staging slot for PR...'
   az webapp deployment slot create \
       --name $(webApp.name) \
       --resource-group $(webApp.resourceGroup) \
       --slot staging-pr-$(System.PullRequest.PullRequestId)
   echo "Created staging slot for PR " $(System.PullRequest.PullRequestId)

  displayName: 'Create CI Staging Slot For PR'
  condition: and(succeeded(), eq(variables['Build.Reason'], 'PullRequest'))
  env:
    SERVICE_PRINCIPAL_PASSWORD: $(servicePrincipal.password)

# Add staging URL to PR comment
- bash: |
   # Add PR comment with link to staging
   echo "Adding PR comment with link to staging environment..."
   message="Created staging environment for PR - https://$(webApp.name)-staging-pr-"$(System.PullRequest.PullRequestId)".azurewebsites.net/"
   echo "message: $message"

   # post message to PR using azure devops rest api
   curl -i --user :$AZURE_DEVOPS_PAT -H "Content-Type: application/json" -H "Accept: aon" -X POST -d '{"comments": [{ "parentCommentId":0, "content": "'"$message"'", "commentType": 1}], "status": 1}' $(System.TeamFoundationCollectionUri)/$(System.TeamProject)/_apis/git/repositories/$(Build.Repository.ID)/pullRequests/$(System.PullRequest.PullRequestId)/threads?api-version=6.1-preview.1

   echo "Done adding PR comment with link to staging environment"
  displayName: 'Add staging URL to PR Comment'
  condition: and(succeeded(), eq(variables['Build.Reason'], 'PullRequest'))
  env:
    AZURE_DEVOPS_PAT: $(azureDevOpsPAT)

# Deploy build to staging environment
- task: AzureRmWebAppDeployment@4
  displayName: 'Deploy to PR Staging'
  inputs:
    ConnectionType: 'AzureRM'
    azureSubscription: 'AbelDemosSubscription(2f42eaaf-1883-462f-a04f-a1d88fa6fd44)'
    appType: 'webApp'
    WebAppName: 'PRWorkflowBlogAppService'
    deployToSlotOrASE: true
    ResourceGroupName: 'PRWorkflowBlog'
    SlotName: 'staging-pr-$(System.PullRequest.PullRequestId)'
    packageForLinux: '$(System.DefaultWorkingDirectory)/**/netlandingpage.zip'
    enableCustomDeployment: true
    DeploymentType: 'webDeploy'
    RemoveAdditionalFilesFlag: true
    ExcludeFilesFromAppDataFlag: false
  condition: and(succeeded(), eq(variables['Build.Reason'], 'PullRequest'))




# Delete staging slot
- bash: |
   # PR Closed, changes merged to main
   echo 'PR close, changes merged to main'

   # get the source version message
   sourceVersionMessage="$(Build.SourceVersionMessage)"
   echo 'build source version message: ' $sourceVersionMessage

   # parse the PR number from the source version message
   prNumber=$(echo $sourceVersionMessage| sed -r 's/^([^.]+).*$/\1/; s/^[^0-9]*([0-9]+).*$/\1/')
   echo 'prNumber: ' $prNumber

   # Login to Azure using service principal
   echo 'Logging into Azure with service principal...'
   az login \
     --service-principal \
     --username $(servicePrincipal.name) \
     --tenant $(servicePrincipal.tenant) \
     --password $SERVICE_PRINCIPAL_PASSWORD
   echo 'Logged into Azure'
   echo ''

   # Delete PR staging slot
   echo 'Deleting staging slot for PR ' $prNumber '...'
   az webapp deployment slot delete \
     --name $(webApp.name) \
     --resource-group $(webApp.resourceGroup) \
     --slot staging-pr-$prNumber
   echo 'Deleted slot for PR ' $prNumber
  displayName: 'Delete Staging Slot For PR'
  condition: and(succeeded(), startsWith(variables['Build.SourceVersionMessage'], 'Merged PR '))
  env:
    SERVICE_PRINCIPAL_PASSWORD: $(servicePrincipal.password)

Secrets should not be stored in the YML so my Azure DevOps PAT and my password for my Azure Service Principal are saved as encrypted variables in the pipeline.

Secret Variables In YML pipeline

Secret variables are not automatically decrypted in YML pipelines and need to be passed to your YML with env:. This shows how to do it.

Using Secrets in YML example

Conclusion

My CI pipeline now perfectly emulates the Static Web Apps pull request workflow. When a pull request happens, my CI build will trigger and the following will occur

  • Use the .net core 5 sdk
  • Restore
  • Build app
  • Run unit tests
  • Publish to staging directory
  • Publish staging as a build artifact
  • Create a CI staging slot for this specific PR using the PR ID as part of the slot name
  • Deploy the app to this specific staging slot
  • Write the URL to this staging slot as a pull request comment

When this pull request is closed out and the changes merged to master, this build will trigger and the following will occure

  • Use the .net core 5 sdk
  • Restore
  • Build app
  • Run unit tests
  • Publish to staging directory
  • Publish staging as a build artifact
  • Delete the PR staging slot

To figure out how to do this, I used the same strategy I use for any pipeline I craft. I ask myself, “How would I do this from the command line?” If there are pre-built tasks that do what I need, I use those. And if there are not, I use a bash or powershell task to execute the command line calls. For this example to implement the Static Web Apps pull request workflow from the command line, I needed to use the Azure CLI and the Azure DevOps REST API. I then sat down at a command prompt and figured out the exact commands to call, and then copied those into a bash task.

Don’t forget about the edge cases. What happens when multiple pull requests happen at once? What happens if tasks in the workflow fail? What happens when a pull request merge happens to the main branch and the staging environment doesn’t exist? Things like that.But with a little care, your pipeline will be able to handle all situations.

Related Links

Static Web Apps

GitHub Actions

Azure App Service

Azure Repos

Azure Pipelines

.NET Core

Branch Policies

Staging with Deployment Slots

Azure CLI

Login to Azure

Creating Slots with Azure CLI

Azure Service Principal

Pipeline Built In Variables

Azure DevOps REST API

Write Comment To Pull Request

Azure DevOps PAT

Azure DevOps Web Hooks

Azure Functions

Delete Slot with the Azure CLI

Using Secret Variables In YML Pipelines

Author

Abel
Principal Cloud Advocate, DevOps Lead

Abel Wang is a Principal Cloud Advocate and DevOps Lead at Microsoft, specializing in DevOps and Azure with a background in application development. He is currently part of Donovan Brown's League of Extraordinary Cloud DevOps Advocates. Before joining Microsoft, Abel spent seven years as a Process Consultant and a Certified Scrum Master helping customers globally develop solutions using agile practices and Team Foundation Server. Prior to that, Abel founded and sold his own software company. ...

More about author

5 comments

Discussion is closed. Login to edit/delete existing comments.

    • AbelMicrosoft employee Author

      You can absolutely use the Azure CLI task and that is super nice because then you don’t have to pass creds around. I used a normal bash task as I’m using this same example in a variety of CI/CD tools and wanted a generic command line way of doing it. Great point and I should have pointed that out in the blog!

      Thanks!

  • Vivien FABING

    Awesome! I love this workflow and enabling it on Azure App Service really seems worth it to me! Thanks for the insights :)

    Just a small question: If I remember correctly, deployment slots are limited regarding your app service tier (5 for Standard tier, and 20 for Premium tier), meaning that for a Standard tier, you need to have no more than 5 simultaneous Pull Request opened (which encourages your team to have short-term life PR,...

    Read more
    • AbelMicrosoft employee Author

      Correct. There is a limit to the number of slots depending on your tier. I also agree that PR’s should not be long living. And if you do have long living PR’s, that’s a flag to review your process and see why you are having long living PR’s. You can also add code to make the PR’s behave in a rolling fashion.