Controlling deployments into XM Cloud and Vercel

Posted 27 March 2024, 22:30 | by | Perma-link

I recently had to put together an Azure DevOps Pipeline to manage deployments of a new Sitecore XM Cloud project with Vercel hosted front-end. Much of the quick start documentation for both XM Cloud and Vercel basically say "Connect to your source control (ideally GitHub) and you're good to go". Clearly great for standing something up quickly but misses a number of important steps:

  1. Is the code suitable? Does it compile, do the tests pass, have we introduced issues through new dependencies - all of this should be checked before we even think about deploying it somewhere.
  2. Do things need to happen in sequence? Does the model that the front-end headless app uses need to exist on the API before it can run? In the quick start recommendations both the Sitecore and Vercel deployments would kick off simultaneously, and sometimes that would cause problems if core models hadn't been published to the Experience Edge before the pages were being built.
  3. Is the team ready for it? If deployments happen whenever new code is available, there's a high likelihood that the environment will restart or change while teams are working on it.
  4. Do we want a single source of truth for what's deployed where? If deployments are triggered as branches are updated, pulled directly through the Sitecore Deploy App or triggered in Vercel, how can we easily see what's been deployed where, tag releases, etc.?

There are always going to be a number of different solutions to these possible issues, but as we're running the project through Azure DevOps, Pipelines was the option I chose.

The Pipeline Templates

I've built the pipeline from a set of reusable modules to reduce the repetition within the pipeline, and also allow us to have a dedicated "PR Checks" pipeline that just focuses on building and testing PRs, but not have to worry about conditions to limit deployments, etc. - I'm just calling out the templates specific to deploying your application to Sitecore XM Cloud and Vercel here, assuming you'll want to ensure your build and test stages meet your requirements.

Setup Sitecore CLI

Takes parameters XmCloudClientId, XmCloudClientSecret
Ensures the build agent is running dotnet 6.0, installs the Sitecore CLI, confirms the version number and plugins, and then logs into Sitecore Cloud and confirms the available projects (always good to confirm that you have what you're expecting!).

parameters:
- name: XmCloudClientId
  type: string
- name: XmCloudClientSecret
  type: string

steps:
- task: UseDotNet@2
  displayName: 'Use .NET Core sdk 6.0.x'
  inputs:
    packageType: sdk
    version: 6.0.x

- task: Bash@3
  displayName: 'Install Sitecore CLI'
  inputs:
    targetType: inline
    script: |
      dotnet tool restore
      dotnet sitecore --version
      dotnet sitecore plugin list

- task: Bash@3
  displayName: 'Login to Sitecore CLI'
  inputs:
    targetType: inline
    script: |
      dotnet sitecore cloud login --client-credentials --client-id ${{ parameters.XmCloudClientId }} --client-secret ${{ parameters.XmCloudClientSecret }} --allow-write
      dotnet sitecore cloud project list

Setup Vercel CLI

Takes optional parameter: VercelCliVersion
Ensures the build agent is using a recent version of node, and then installs the Vercel CLI and confirms the version number.

parameters:
- name: 'VercelCliVersion'
  type: 'string'
  default: ''

steps:
- task: UseNode@1
  displayName: 'Setup Node.js'
  inputs:
    version: 18.x

- task: bash@3
  displayName: 'Install Vercel CLI'
  inputs:
    targetType: 'inline'
    script: |
      npm install -g vercel${{ parameters.VercelCliVersion }}
      vercel --version

Deploy XM Cloud

Takes parameters: XmCloudEnvironmentId
Deploys the current working directory to XM Cloud via the Sitecore CLI cloud plugin. Once it's succeeded, store the deployment ID in an output variable for later use.

parameters:
- name: XmCloudEnvironmentId
  type: string

steps:
- task: Bash@3
  displayName: 'Deploy Project to XM Cloud'
  name: deployXmCloud
  inputs:
    targetType: inline
    script: |
      echo Deploying to XM Cloud
      result=$(dotnet sitecore cloud deployment create --environment-id ${{ parameters.XmCloudEnvironmentId }} --upload --json)
      echo $result
      isTimedOut=$(echo $result | jq ' .IsTimedOut')
      isCompleted=$(echo $result | jq ' .IsCompleted')
      deploymentId=$(echo $result | jq ' .DeploymentId')
      echo "##vso[task.setvariable variable=deploymentId;isOutput=true]$deploymentId"
      if [ $isTimedOut = true ]
      then
          echo "Operation Timed Out."
          exit -1
      fi
      if ! [ $isCompleted = true ]
      then
          echo "Operation Failed."
          exit -1
      fi
      echo "Deployment Completed"

Deploy Vercel

Takes parameters: VercelToken, VercelProjectId, VercelTeamId
When working with an account that's a member of multiple teams, your common token needs to used with a Team Id to ensure correct scoping. Triggers a deployment to Vercel from the current working directory.

parameters:
- name: VercelToken
  type: string
- name: VercelProjectId
  type: string
- name: VercelTeamId
  type: string

steps:
- task: bash@3
  displayName: 'Deploy Front End to Vercel Production'
  inputs:
    targetType: 'inline'
    script: |
      vercel deploy --prod --token ${{ parameters.VercelToken }}
  env:
    VERCEL_ORG_ID: ${{ parameters.VercelTeamId }}
    VERCEL_PROJECT_ID: ${{ parameters.VercelProjectId }}

Deploy XM Cloud Promote

Takes parameters: XmCloudEnvironmentId, XmCloudDeploymentId Promotes an existing Sitecore XM Cloud deployment to a new environment. On success, stores the deployment ID as an output parameter for later use.

 parameters:
- name: XmCloudEnvironmentId
  type: string
- name: XmCloudDeploymentId
  type: string

steps:
- task: Bash@3
  displayName: 'Promote Project to Next Environment in XM Cloud'
  name: promoteXmCloud
  inputs:
    targetType: inline
    script: |
      echo Promoting environment in XM Cloud
      result=$(dotnet sitecore cloud environment promote --environment-id ${{ parameters.XmCloudEnvironmentId }} --source-id ${{ parameters.XmCloudDeploymentId }} --json)
      echo $result
      isTimedOut=$(echo $result | jq ' .IsTimedOut')
      isCompleted=$(echo $result | jq ' .IsCompleted')
      deploymentId=$(echo $result | jq ' .DeploymentId')
      echo "##vso[task.setvariable variable=deploymentId;isOutput=true]$deploymentId"
      echo "Deployment Completed"

The Finished Pipeline

Diagram showing the path the full pipeline takes

By setting the pipeline up with these discreet stages, we're able to easily extend or adjust the pipeline, as well as add other stages as needed (for example deploying a Design System version of the front-end application to document components and styles), as well as running a manual release that only deploys the Sitecore components, or the headless Vercel application.

Because both Sitecore XM Cloud and Vercel really want to perform the build process for you (indeed, next.js seems to require a build per environment, with environment variables often baked in to the application in addition to the static page generation requiring this), we aren't able to create a single build package and store that with the release, however XM Cloud does at least allow us to "promote" a deployment to a new environment after the initial deployment, which is close enough for now - you'll also notice that we need to supply a large set of environment variables to the build-node template - these are typically the environment variables that you'd set that allow the Sitecore JSS next.js application to communicate with a Sitecore instance and generate the various parts that are required to build the headless application.

We have libraries set up to hold the various parameters and secrets used throughout - if you ensure you set your secrets as secrets in the library, the Pipeline runtime takes care of not exposing those values in logs, etc. which is nice.

Environments can be configured with approvals and checks, allowing us to gate deployments pending team readiness - no more uncontrolled deployments into QA as soon as a PR is changed, meaning they can release code when they've finished testing the current crop of features in a stable environment.

trigger:
  branches:
    include:
    - develop
    - release/*
  paths:
    exclude:
    - .github/**
    - .vscode/**
    - docker/**
    - poc/**

parameters:
- name: VercelCliVersion
  type: string
  default: '@latest'

variables:
- group: XMC-Build

name: $(BuildPrefix)$(Rev:r)

- stage: ScanAndBuild
  jobs:
  - job: XmCloudBuild
    displayName: 'Scan and Build the XM Cloud Application'
    pool:
      vmImage: 'windows-latest'
    steps:
    - checkout: self
      clean: true
    - template: /.azuredevops/templates/build/build-dotnet.yml
      parameters:
        BuildConfiguration: 'Release'

  - job: FrontendBuild
    displayName: 'Scan and Build the Frontend applications'
    pool:
      vmImage: 'ubuntu-latest'
    variables:
    - group: XMC-Dev
    steps:
    - checkout: self
      clean: true

    - template: /.azuredevops/templates/build/build-node.yml
      parameters:
        GraphQlEndpoint: $(GraphQlEndpoint)
        JSSAppName: $(JSSAppName)
        NextPublicCdpKey: $(NextPublicCdpKey)
        NextPublicCdpPos: $(NextPublicCdpPos)
        NextPublicCdpTargetUrl: $(NextPublicCdpTargetUrl)
        NextPublicUrl: $(NextPublicUrl)
        SitecoreApiKey: $(SitecoreApiKey)
        SitecoreApiHost: $(SitecoreApiHost)
        SitecoreEdgeContextId: $(SitecoreEdgeContextId)
        SitecoreSiteName: $(SitecoreSiteName)
        XmCloudEnvId: $(XmCloudEnvironmentId)
    
    - template: /.azuredevops/templates/build/build-storybook.yml

- stage: QaSitecore
  dependsOn:
  - ScanAndBuild
  condition: and(succeeded(), or(startsWith(variables['Build.SourceBranch'], 'refs/heads/develop'), startsWith(variables['Build.SourceBranch'], 'refs/heads/release/'), startsWith(variables['Build.SourceBranch'], 'refs/heads/hotfix/')))
  variables:
  - group: XMC-QA
  jobs:
  - deployment: QaSitecore
    displayName: 'Deploy Sitecore to QA'
    environment: 'XMC-QA'
    pool:
      vmImage: 'ubuntu-latest'
    strategy:
      runOnce:
        deploy:
          steps:
          - checkout: self
            clean: true

          - template: /.azuredevops/templates/utils/setup-sitecore-cli.yml
            parameters:
              XmCloudClientId: $(XmCloudClientId)
              XmCloudClientSecret: $(XmCloudClientSecret)

          - template: /.azuredevops/templates/deploy/deploy-xmcloud.yml
            parameters:
              XmCloudEnvironmentId: $(XmCloudEnvironmentId)

- stage: QaVercel
  dependsOn:
  - QaSitecore
  condition: and(succeeded(), or(startsWith(variables['Build.SourceBranch'], 'refs/heads/develop'), startsWith(variables['Build.SourceBranch'], 'refs/heads/release/'), startsWith(variables['Build.SourceBranch'], 'refs/heads/hotfix/')))
  variables:
  - group: XMC-QA
  jobs:
  - deployment: QaVercel
    displayName: 'Deploy Vercel to QA'
    environment: 'XMC-QA'
    pool:
      vmImage: 'ubuntu-latest'
    strategy:
      runOnce:
        deploy:
          steps:
          - checkout: self
            clean: true

          - template: /.azuredevops/templates/utils/setup-vercel-cli.yml
            parameters:
              VercelCliVersion: ${{ parameters.VercelCliVersion }}

          - template: /.azuredevops/templates/deploy/deploy-vercel.yml
            parameters:
              VercelProjectId: $(VercelProjectId)
              VercelToken: $(VercelToken)
              VercelTeamId: $(VercelTeamId)

- stage: UatSitecore
  dependsOn:
  - ScanAndBuild
  - QaSitecore
  condition: and(succeeded(), or(startsWith(variables['Build.SourceBranch'], 'refs/heads/release/'), startsWith(variables['Build.SourceBranch'], 'refs/heads/hotfix/')))
  variables:
  - name: QaDeploymentId
    value: $[stageDependencies.QaSitecore.QaSitecore.outputs['QaSitecore.deployXmCloud.deploymentId']]
  - group: XMC-UAT
  jobs:
  - deployment: UatSitecore
    displayName: 'Promote Sitecore to UAT'
    environment: 'XMC-UAT'
    pool:
      vmImage: 'ubuntu-latest'
    strategy:
      runOnce:
        deploy:
          steps:
          - checkout: self
            clean: true
          
          - template: /.azuredevops/templates/utils/setup-sitecore-cli.yml
            parameters:
              XmCloudClientId: $(XmCloudClientId)
              XmCloudClientSecret: $(XmCloudClientSecret)

          - template: /.azuredevops/templates/deploy/deploy-xmcloud-promote.yml
            parameters:
              XmCloudEnvironmentId: $(XmCloudEnvironmentId)
              XmCloudDeploymentId: $(QaDeploymentId)

- stage: UatVercel
  dependsOn:
  - UatSitecore
  - QaVercel
  condition: and(succeeded(), or(startsWith(variables['Build.SourceBranch'], 'refs/heads/release/'), startsWith(variables['Build.SourceBranch'], 'refs/heads/hotfix/')))
  variables:
  - group: XMC-UAT
  jobs:
  - deployment: UatVercel
    displayName: 'Deploy Vercel to UAT'
    environment: 'XMC-UAT'
    pool:
      vmImage: 'ubuntu-latest'
    strategy:
      runOnce:
        deploy:
          steps:
          - checkout: self
            clean: true

          - template: /.azuredevops/templates/utils/setup-vercel-cli.yml
            parameters:
              VercelCliVersion: ${{ parameters.VercelCliVersion }}

          - template: /.azuredevops/templates/deploy/deploy-vercel.yml
            parameters:
              VercelProjectId: $(VercelProjectId)
              VercelToken: $(VercelToken)
              VercelTeamId: $(VercelTeamId)

- stage: ProdSitecore
  dependsOn:
  - ScanAndBuild
  - QaSitecore
  - UatSitecore
  condition: and(succeeded(), or(startsWith(variables['Build.SourceBranch'], 'refs/heads/release/'), startsWith(variables['Build.SourceBranch'], 'refs/heads/hotfix/')))
  variables:
  - name: UatDeploymentId
    value: $[stageDependencies.UatSitecore.UatSitecore.outputs['UatSitecore.promoteXmCloud.deploymentId']]
  - group: XMC-Prod
  jobs:
  - deployment: ProdSitecore
    displayName: 'Promote Sitecore to Prod'
    environment: 'XMC-Prod'
    pool:
      vmImage: 'ubuntu-latest'
    strategy:
      runOnce:
        deploy:
          steps:
          - checkout: self
            clean: true
          
          - template: /.azuredevops/templates/utils/setup-sitecore-cli.yml
            parameters:
              XmCloudClientId: $(XmCloudClientId)
              XmCloudClientSecret: $(XmCloudClientSecret)

          - template: /.azuredevops/templates/deploy/deploy-xmcloud-promote.yml
            parameters:
              XmCloudEnvironmentId: $(XmCloudEnvironmentId)
              XmCloudDeploymentId: $(UatDeploymentId)

- stage: ProdVercel
  dependsOn:
  - ProdSitecore
  - UatVercel
  condition: and(succeeded(), or(startsWith(variables['Build.SourceBranch'], 'refs/heads/release/'), startsWith(variables['Build.SourceBranch'], 'refs/heads/hotfix/')))
  variables:
  - group: XMC-Prod
  jobs:
  - deployment: ProdVercel
    displayName: 'Deploy Vercel to Prod'
    environment: 'XMC-Prod'
    pool:
      vmImage: 'ubuntu-latest'
    strategy:
      runOnce:
        deploy:
          steps:
          - checkout: self
            clean: true

          - template: /.azuredevops/templates/utils/setup-vercel-cli.yml
            parameters:
              VercelCliVersion: ${{ parameters.VercelCliVersion }}

          - template: /.azuredevops/templates/deploy/deploy-vercel.yml
            parameters:
              VercelProjectId: $(VercelProjectId)
              VercelToken: $(VercelToken)
              VercelTeamId: $(VercelTeamId)

Filed under: Sitecore

Restricting access to Sitecore Media Items

Posted 03 March 2015, 11:00 | by | Perma-link

I recently had a requirement to lock down some media items (PDFs in this case) within Sitecore so that only certain logged in users could access them. In principle this is trivially easy - ensure the users are in the right roles, remove read access from the extranet\anonymous user and grant read access to the specific roles. However, as always, the devil is in the details.

Whilst the above steps did work and users were correctly sent to the login page there was a problem - once the user logged in, they were just sent to the home page of site rather than being returned to the item they'd requested.

Checking the web.config I found the following setting, which defaults to false:

<setting name="Authentication.SaveRawUrl" value="true" />

But setting it to true here didn't actually make any difference - because the out of the box MediaRequestHandler ignores this value. I'm not really sure whether that makes sense at all - if I lock down some images for example, but then include them on a publicly accessible page the user isn't going to be prompted to log in, they'd just get broken images as the browser requests an image but gets HTML in response, but in the context of a PDF or other document surely you'd want to log in and be returned to the correct place.

Anyway, the solution was fairly straight forward. I created a new RestrictedMediaRequestHandler that inherits MediaRequestHandler and then overrode only the DoProcessRequest method:

/// <summary>
/// Extends the Sitecore MediaRequestHandler to include the requested
/// URL in the redirect to the login page.
/// </summary>
public class RestrictedMediaRequestHandler : MediaRequestHandler
{
  protected override bool DoProcessRequest(HttpContext context)
  {
    Assert.ArgumentNotNull(context, "context");
    MediaRequest request = MediaManager.ParseMediaRequest(context.Request);
    if (request == null) {
      return false;
    }

    Media media = MediaManager.GetMedia(request.MediaUri);
    if (media != null) {
      // We've found the media item, so send it to the user
      return DoProcessRequest(context, request, media);
    }

    using (new SecurityDisabler()) {
      // See if the media item exists but the user doesn't have access
      media = MediaManager.GetMedia(request.MediaUri);
    }

    string str;
    if (media == null) {
      // The media item doesn't exist, send the user to a 404
      str = Settings.ItemNotFoundUrl;
    } else {
      Assert.IsNotNull(Context.Site, "site");
      str = Context.Site.LoginPage != string.Empty ?
          Context.Site.LoginPage : Settings.NoAccessUrl;

      if (Settings.Authentication.SaveRawUrl) {
        var list = new List<string>(new[]
                                    {
                                        "item",
                                        Context.RawUrl
                                    });

        str = WebUtil.AddQueryString(str, list.ToArray());
      }
    }

    HttpContext.Current.Response.Redirect(str);
            
    return true;
  }
}

Then I updated the web.config to tell the sitecore media handler to use this new handler instead of the default one, and all was well in the world:

<add verb="*" path="sitecore_media.ashx"
     type="Custom.Infrastructure.Sitecore.RestrictedMediaRequestHandler, Custom.Infrastructure"
     name="Custom.RestrictedMediaRequestHandler" />

And now when a user requests a PDF they don't have access to they are sent to a login page that can return them to the PDF afterwards.

Filed under: Sitecore

Long Running Sitecore Workflows

Posted 31 March 2014, 15:42 | by | Perma-link

Note: This has been sitting in my queue for nearly a year, mainly because I didn't find a nice solution that worked with workflows - but I thought I'd finish it up and move on - 10/02/2015

I've been looking into some options for informing editors about the state of long running processes when carrying out a Sitecore workflow action. Typically, the UI will freeze while the workflow action is happening - which can cause issues with browsers (I'm looking at you Chrome) that decide that the page has timed out and just kill it.

In our particular case, we are generating a static copy of our site (as XML, html and a packaged .zip container) for use within a Magazine App container - the content is all hosted via a CDN, and only gets updated when a new issue is published. However, processing a number of issues and languages can take a little while.

I'm currently favouring a fairly simple Sitecore Job running in the context of a ProgressBox, which is working, but has a few rough edges.

The key advantages this method has are:

  • It keeps the connection between the browser and the server active, which keeps Chrome happy.
  • There's a visual indication that "something is happening", which keeps editors happy.

The issues I'm currently looking into however include:

  1. Because the task is running asynchronously, the workflow action "completes" (at least from a code point of view) before the Job finishes.
  2. Because of 1, there's no way to stop the workflow and mark it as "failed" if there are issues with the process.

Not long after I started writing this, the client requested that we remove the various status checks from the workflow conditions (so they could run the process for staging without having to complete the entire magazine) and I came to the conclusion that having this as a Sitecore Workflow didn't really work for because the editors workflow was: work on a few pages, package for staging, work on a few more pages, package to staging, etc. until it was ready to package to production - with the Workflow in place they had to keep rejecting the build to staging so they could re-run that step.

We therefore needed to replace the workflow with some custom ribbon buttons allowing the editors the package the content for staging or production as needed.

Filed under: Sitecore