How to deploy SharePoint Apps with VSTS Release Management

Continuous Deployment of SharePoint Apps (a.k.a Add-Ins) is tricky. The app package is intended to be created for every environment because it must contain a client id and secret. There is also no simple way to automatically trust the app. I show you in this post how you can create a release pipeline with VSTS Release Management and some PowerShell scripts to fully automate the deployment of SharePoint Apps. The example deploys to SharePoint Online – but the same scripts can be used to deploy to your on premise SharePoint.

Overview: Continuous Deployment with Release Management

The idea of continuous deployment is, that each push to your repository automatically triggers a build that is deployed to an environment. Using branches and different builds you can deploy different versions of your code to different stages. A simple release pipeline could look like this.

Azure Release Management

The developers work in their own Azure Subscriptions (MSDN) and test their code there. If they push a commit to the develop branch a continuous integration build is triggered that runs all the unit tests and packages the app. The app is then deployed to a resource group in an integration subscription and can there be tested automatically and/or manually.

If the code is ready for a release to production it is merged to the master branch using a pull request. The merge also triggers a build that deploys to different subscriptions in sequence. You can add automated or manual approval steps.

Package your app and web site

To create a package that we can deploy to multiple environments we have to create packages with tokens that we can replace during deployment – for the app and for the app web.

In the publish dialog of the app (right click and select Publish…) you can add tokens for the app (Client Id and Client Secret).

Package SharePoint App

Here you also can create a new publish profile for the app web. Use “Web deploy package” and add a token for the site name.

image

To replace the Client ID, Client Secret and optional other configurations (like Connection Strings or Endpoints) you can use a Parameter.xml file. The file is used by WebDeploy and contains XPath expressions for the values that should be replaced. Just add the file to the root of your project and set the build action to none.

Here is a sample parameter file:

<?xml version="1.0" encoding="utf-8" ?>
<parameters>
  <parameter name="ClientId" description="Value for ClientId here." defaultvalue="__ClientId__" tags="applicationSettings">
    <parameterentry kind="XmlFile" scope="web.config$" match="/configuration/appSettings/add[@key='ClientId']/@value" />
  </parameter>
  <parameter name="ClientSecret" description="Value for ClientSecret here." defaultvalue="__ClientSecret__" tags="applicationSettings">
    <parameterentry kind="XmlFile" scope="web.config$" match="/configuration/appSettings/add[@key='ClientSecret']/@value" />
  </parameter>
  <parameter name="DBEndPointUrl" description="" defaultvalue="__DBEndPointUrl__" tags="applicationSettings">
    <parameterentry kind="XmlFile" scope="web.config$" match="/configuration/applicationSettings/CalculatorApp.DocumentDbStores.Properties.Settings/setting[@name='EndPointUrl']/value" />
  </parameter>
  <parameter name="DBAuthorizationKey" description="" defaultvalue="__DBAuthorizationKey__" tags="applicationSettings">
    <parameterentry kind="XmlFile" scope="web.config$" match="/configuration/applicationSettings/CalculatorApp.DocumentDbStores.Properties.Settings/setting[@name='AuthorizationKey']/value" />
  </parameter>
</parameters>

Build

To create the app during the server build we have to specify /P:IsPackaging=True as the MSBuild Argument for the Visual Studio Build task. To create the package for the app web from the previous step you also have to specify /p:DeployOnBuild=true /p:PublishProfile=Package. This will result in two packages getting created. I’ve already written about that. To make sure the correct package is published you can force it to go to the staging directory with the following argument: /p:PackageLocation=”$(build.stagingDirectory)”.

image

To publish the deployment scripts and the app package you can also copy them to the staging directory using a the copy file task.

image

image

Then you create the build output by publishing the entire staging directory.

image

The result is a neat little package that contains the app, the deployment script, and the web deploy package with the tokens that are replaced before deployment to each environment.

image

Release

In the release definition you have to add all the tokens as variables for your environment.

image

The release then calls the script Replace-Tokens.ps1 that replaces all the tokens in the parameter file.

image

The script is originally from my friend Colin Dembovski. He also created a build task that can directly used.

My own implementation of the script can also be found on github. The trick is that the script pulls the variables from the environment over the environment provider from PowerShell. So there is no need to specify individual tokens. Just add new Tokens to you parameter file and to the environment in release Management.

[CmdletBinding()]
Param
(
    [Parameter(Mandatory=$true, ValueFromPipelineByPropertyName=$true, ValueFromPipeline=$true, Position=0)]
    [ValidateNotNullOrEmpty()]
    [string]$RootFolder,

    [Parameter(Mandatory=$true, ValueFromPipelineByPropertyName=$true, ValueFromPipeline=$true, Position=1)]
    [ValidateNotNullOrEmpty()]
    [string]$FileName
)

function Replace-Tokens
{
    [CmdletBinding()]
    Param
    (
        # Hilfebeschreibung zu Param1
        [Parameter(Mandatory=$true, ValueFromPipelineByPropertyName=$true, ValueFromPipeline=$true, Position=0)]
        [ValidateNotNullOrEmpty()]
        [ValidateScript({ Test-Path $_ })]
        [string]$FileFullName
    )

    Write-Verbose &quot;Replace tokens in '$FileFullName'...&quot;

    # get the environment variables
    $vars = Get-ChildItem -Path env:*

    # read in the setParameters file
    $contents = Get-Content -Path $FileFullName

    # perform a regex replacement
    $newContents = &quot;&quot;
    $contents | ForEach-Object {

        $line = $_
        if ($_ -match &quot;__(\w+)__&quot;) {
            $setting = $vars | Where-Object { $_.Name -eq $Matches[1]  }

            if ($setting) {
                Write-Verbose &quot;Replacing key '$($setting.Name)' with value '$($setting.Value)' from environment&quot;
                $line = $_ -replace &quot;__(\w+)__&quot;, $setting.Value
            }
        }

        $newContents += $line + [Environment]::NewLine
    }

    Write-Verbose -Verbose &quot;Save content to '$FileFullName'.&quot;
    Set-Content $FileFullName -Value $newContents

    Write-Verbose &quot;Done&quot;
}

Write-Verbose &quot;Look for file '$FileName' in '$RootFolder'...&quot;

$files = Get-ChildItem -Path $RootFolder -Recurse -Filter $FileName

Write-Verbose &quot;Found $($files.Count) files.&quot;

$files | ForEach-Object { Replace-Tokens -FileFullName $_.FullName }

Write-Verbose &quot;All files processed.&quot;

The deploy the website we have to execute the batch file from the deployment package. As Parameters we have to specify the msdeploy.axd from the azure website (/M:https://$(WebDeploySiteName).scm.azurewebsites.net:443/msdeploy.axd) and the user name and passwort from the environment variables (/u:$(AzureUserName) /p:$(AzurePassword)). We also have to specify /y to do a quite deployment and set the authentication to basic.

image

The most tricky part is to deploy the app package. There is no out of the box solution to automate the trust of apps. Apps in the App Catalogue cannot be deployed automatically at all. So the only way I know is to use side loading to directly deploy an app to a site and use IE automation to “click” the trust button.

You will need your own build agent to run the script and you will have to install office. You also could install some assemblies to the GAC – but  I found it easier to install office.

image

So let’s step thru the Deploy-SPApp.ps1 script and go into details what each step does.

For each environment we use a separate client id for security purpose. So the first part is to open the app (a zip file) and read the app manifest.

Write-Verbose &quot;Open zip file '$Path'...&quot;
$zip =  [System.IO.Compression.ZipFile]::Open($Path, &quot;Update&quot;)

$fileToEdit = &quot;AppManifest.xml&quot;
$file = $zip.Entries.Where({$_.name -eq $fileToEdit})

Write-Verbose &quot;Read app manifest from '$file'.&quot;
$desiredFile = [System.IO.StreamReader]($file).Open()
[xml]$xml = $desiredFile.ReadToEnd()
$desiredFile.Close()

We then replace the client id with the value from the environment variable.

if ($env:ClientId){
    Write-Verbose &quot;Found ClientId '$env:ClientId' in environment. Replace it in app.&quot;
    $xml.App.AppPrincipal.RemoteWebApplication.ClientId = $env:ClientId
}

There also might be more values that have to be replaced (in case you use remote event receivers). Then we update the app manifest and save the file.

# Save file
Write-Verbose &quot;Save manifest to '$file'.&quot;
$desiredFile = [System.IO.Stream]($file).Open()
$desiredFile.SetLength(0)
$xml.Save($desiredFile)
$desiredFile.Flush()
$desiredFile.Close()
$desiredFile.Dispose()

# //...

$zip.Dispose()

In the next part open a connection the SharePoint site using CSOM.

Write-Host &quot;Connect to '$WebUrl' as '$DeployUserName'...&quot;
$clientContext = New-Object Microsoft.SharePoint.Client.ClientContext($webUrl)
$clientContext.Credentials = New-Object Microsoft.SharePoint.Client.SharePointOnlineCredentials($DeployUserName, (ConvertTo-SecureString $DeployPassword -AsPlainText -Force)) 

$web = $clientContext.Web
$clientContext.Load($web)

$clientContext.ExecuteQuery();

Write-Host &quot;Successfully connected to '$WebUrl'...&quot;

The next step is to install the app using side loading and wait for SharePoint to complete the installation.

function Install-App($clientContext, $appPackage, $productId) {
    $appName = [System.IO.Path]::GetFileNameWithoutExtension($appPackage)
    $web = $clientContext.Web

    Write-Verbose &quot;Start to install app $appName...&quot;

    # Try to uninstall any existing app instances first.
    Uninstall-App $clientContext $productId

    Write-Verbose &quot;Installing app $appName...&quot;
    $appInstance = $web.LoadAndInstallAppInSpecifiedLocale(([System.IO.FileInfo]$appPackage).OpenRead(), $web.Language)
    $clientContext.Load($appInstance)
    $clientContext.ExecuteQuery()

    $appInstance = WaitForAppOperationComplete $clientContext $appInstance.Id

    if (!$appInstance -Or $appInstance.Status -ne [Microsoft.SharePoint.Client.AppInstanceStatus]::Installed)
    {
        if ($appInstance -And $appInstance.Id)
        {
            Write-Error &quot;App installation failed. To check app details, go to '$($web.Url.TrimEnd('/'))/_layouts/15/AppMonitoringDetails.aspx?AppInstanceId=$($appInstance.Id)'.&quot;
        }

        throw &quot;App installation failed.&quot;
    }

    return $appInstance.Id
}

Then we start with the magic. We create an internet explorer instance and navigate to the authorize page to trust the app.

$authorizeURL = &quot;$($WebUrl.TrimEnd('/'))/_layouts/15/appinv.aspx?AppInstanceId={$AppInstanceId}&quot;

$ie = New-Object -com internetexplorer.application

$ie.Visible = $false
$ie.Navigate2($authorizeURL)

On prem you can skip the next part – but in the cloud we have sign in to Office 365. We enter username and password in the corresponding input fields and submit the page using JavaScript.

if ($ie.Document.Title -match &quot;Sign in to Office 365.*&quot;) {

    Write-Verbose &quot;Authenticate $UserName to O365...&quot;
    # Authorize against O365
    $useAnotherLink = $ie.Document.getElementById(&quot;use_another_account_link&quot;)
    if ($useAnotherLink) {

        WaitFor-IEReady $ie

        $useAnotherLink.Click()

        WaitFor-IEReady $ie

    }

    $credUseridInputtext = $ie.Document.getElementById(&quot;cred_userid_inputtext&quot;)
    $credUseridInputtext.value = $UserName

    $credPasswordInputtext = $ie.Document.getElementById(&quot;cred_password_inputtext&quot;)
    $credPasswordInputtext.value = $Password

    WaitFor-IEReady $ie

    # make a jQuery call
    $result = Invoke-JavaScript -IE $ie -Command &quot;`nPost.IsSubmitReady();`nsetTimeout(function() {`nPost.SubmitCreds();`n}, 1000);&quot;

    WaitFor-IEReady $ie -initialWaitInSeconds 5
}

In the authorization form we just have to click the correct button. And that’s it. The app is now deployed and trusted you you can run your automated (or manual) tests against it.

if ($ie.Document.Title -match &quot;Do you trust.*&quot;) {
    sleep -seconds 5

    $button = $ie.Document.getElementById(&quot;ctl00_PlaceHolderMain_BtnAllow&quot;)

	if ($button -eq $null) {
		$button = $ie.Document.getElementById(&quot;ctl00_PlaceHolderMain_LnkRetrust&quot;)
	}

    if ($button -eq $null) {
        throw &quot;Could not find button to press&quot;
    }else{
        $button.click()

        WaitFor-IEReady $ie

        #if the button press was successful, we should now be on the Site Settings page..
        if ($ie.Document.title -like &quot;*trust*&quot;) {
            throw &quot;Error: $($ie.Document.body.getElementsByClassName(&quot;ms-error&quot;).item().InnerText)&quot;
        }else{
            Write-Verbose &quot;App was trusted successfully!&quot;
        }
    }
}else{
    throw &quot;Unexpected page '$($ie.LocationName)' was loaded. Please check your url.&quot;
}

Conclusion

Building an end to end release pipeline for SharePoint online apps is very complex because it involves a lot of steps. Apps are not really designed for automatic deployment. I tried to pick out the most interesting parts here – but if your missing a piece of the puzzle then drop me a comment in the blog or contact me via twitter and I will try to help. The good thing is that the tooling with new new build and release management system in TFS / VSTS makes it a lot easier to build and deploy and to focus on the scripts and tasks for the app.

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out / Change )

Twitter picture

You are commenting using your Twitter account. Log Out / Change )

Facebook photo

You are commenting using your Facebook account. Log Out / Change )

Google+ photo

You are commenting using your Google+ account. Log Out / Change )

Connecting to %s