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: Azure, DevOps, Next.js, Sitecore, Vercel