# CI/CD recipe: Azure Pipelines

> Complete Azure Pipelines YAML for authenticating, packing, publishing, deploying a UiPath Solution, and running Test Manager tests.

This page gives you a complete `azure-pipelines.yml` that **installs the CLI, authenticates with an External Application, packs and publishes a UiPath Solution, deploys it to Orchestrator, and runs a Test Manager suite** against the deployment. The pipeline is self-contained: copy it into the root of your repo, wire two secrets, and it runs.

For the underlying principles — auth, caching, tool pre-install, version pinning — see [How-to: deploy to Orchestrator from CI](./howto-deploy-from-ci.md). This page focuses on the Azure Pipelines syntax.

## Prerequisites

Before copying the YAML below:

1. **Create an External Application** in UiPath with the `OR.*` scopes your pipeline needs. See [Authentication — Flow 2](./authentication.md#flow-2-external-application-client-credentials).
2. **Store the secrets** in an Azure DevOps **variable group**:
   - Project Settings → Pipelines → **Library** → New variable group (for example, `uipath-prod`).
   - Add `UIPATH_CLIENT_ID` and `UIPATH_CLIENT_SECRET` — mark both as **secret** (padlock icon).
   - Optionally add `UIPATH_TENANT` (not a secret).
3. **Link the variable group** to the pipeline (see the `variables` block below).
4. **Provision a Test Manager project and test set** (if you want the test stage). You will need the `TEST_SET_KEY` (format `PROJECT:42`) and `PROJECT_KEY`. See [How-to: run tests from the CLI](./howto-run-tests.md).

## azure-pipelines.yml

```yaml
trigger:
  branches:
    include: [ main ]

pr:
  branches:
    include: [ main ]

variables:
- group: uipath-prod           # contains UIPATH_CLIENT_ID, UIPATH_CLIENT_SECRET
- name:  CLI_VERSION
  value: '1.0.0'
- name:  NODE_VERSION
  value: '20.x'
- name:  SOLUTION_NAME
  value: 'my-solution'
- name:  SOLUTION_DIR
  value: '$(Build.SourcesDirectory)/my-solution'
- name:  OUTPUT_DIR
  value: '$(Build.ArtifactStagingDirectory)'
- name:  SOLUTION_VERSION
  value: '1.2.0-ci.$(Build.BuildId)'

# Path where npm places globally-installed packages. Used to cache the CLI + tools.
- name:  NPM_GLOBAL_CACHE
  value: '$(HOME)/.npm-global/lib/node_modules'

stages:

- stage: Build
  displayName: 'Pack the Solution'
  jobs:
  - job: pack
    pool:
      vmImage: 'ubuntu-latest'
    steps:

    - task: NodeTool@0
      displayName: 'Use Node.js $(NODE_VERSION)'
      inputs:
        versionSpec: $(NODE_VERSION)

    - task: Cache@2
      displayName: 'Cache npm global (@uipath/cli)'
      inputs:
        key:  'uip | "$(Agent.OS)" | $(CLI_VERSION)'
        path: $(NPM_GLOBAL_CACHE)

    - script: |
        set -euo pipefail
        mkdir -p "$HOME/.npm-global"
        npm config set prefix "$HOME/.npm-global"
        export PATH="$HOME/.npm-global/bin:$PATH"
        echo "##vso[task.prependpath]$HOME/.npm-global/bin"

        if ! command -v uip >/dev/null; then
          npm install -g "@uipath/cli@$(CLI_VERSION)"
        fi

        uip --version
      displayName: 'Install UiPath CLI'

    - script: |
        set -euo pipefail
        uip solution pack "$(SOLUTION_DIR)" "$(OUTPUT_DIR)" \
          --name "$(SOLUTION_NAME)" \
          --version "$(SOLUTION_VERSION)"
      displayName: 'Pack Solution'

    - publish: '$(OUTPUT_DIR)'
      artifact: solution-zip
      displayName: 'Publish artifact'

- stage: Deploy
  displayName: 'Publish to feed and deploy to Orchestrator'
  dependsOn: Build
  jobs:
  - deployment: deploy
    environment: 'uipath-prod'      # Azure DevOps environment — gates / approvals live here
    pool:
      vmImage: 'ubuntu-latest'
    strategy:
      runOnce:
        deploy:
          steps:

          - task: NodeTool@0
            displayName: 'Use Node.js $(NODE_VERSION)'
            inputs:
              versionSpec: $(NODE_VERSION)

          - task: Cache@2
            displayName: 'Cache npm global (@uipath/cli)'
            inputs:
              key:  'uip | "$(Agent.OS)" | $(CLI_VERSION)'
              path: $(NPM_GLOBAL_CACHE)

          - script: |
              set -euo pipefail
              mkdir -p "$HOME/.npm-global"
              npm config set prefix "$HOME/.npm-global"
              export PATH="$HOME/.npm-global/bin:$PATH"
              echo "##vso[task.prependpath]$HOME/.npm-global/bin"

              if ! command -v uip >/dev/null; then
                npm install -g "@uipath/cli@$(CLI_VERSION)"
              fi
            displayName: 'Install UiPath CLI'

          - script: |
              set -euo pipefail
              uip login \
                --client-id env.UIPATH_CLIENT_ID \
                --client-secret env.UIPATH_CLIENT_SECRET \
                --tenant "$UIPATH_TENANT"
            displayName: 'Authenticate'
            env:
              UIPATH_CLIENT_ID:     $(UIPATH_CLIENT_ID)
              UIPATH_CLIENT_SECRET: $(UIPATH_CLIENT_SECRET)
              UIPATH_TENANT:        $(UIPATH_TENANT)

          - download: current
            artifact: solution-zip

          - script: |
              set -euo pipefail
              ARTIFACT=$(find "$(Pipeline.Workspace)/solution-zip" -maxdepth 1 -name "*.zip" | head -1)
              uip solution publish "$ARTIFACT"
            displayName: 'Publish to tenant feed'

          - script: |
              set -euo pipefail
              uip solution deploy run \
                --name "$(SOLUTION_NAME)-$(Build.BuildId)" \
                --package-name "$(SOLUTION_NAME)" \
                --package-version "$(SOLUTION_VERSION)" \
                --folder-name MySolution \
                --folder-path Shared
            displayName: 'Deploy to Orchestrator'

- stage: Test
  displayName: 'Run Test Manager suite'
  dependsOn: Deploy
  condition: and(succeeded(), ne(variables['TEST_SET_KEY'], ''))
  jobs:
  - job: test
    pool:
      vmImage: 'ubuntu-latest'
    steps:

    - task: NodeTool@0
      displayName: 'Use Node.js $(NODE_VERSION)'
      inputs:
        versionSpec: $(NODE_VERSION)

    - task: Cache@2
      displayName: 'Cache npm global (@uipath/cli)'
      inputs:
        key:  'uip | "$(Agent.OS)" | $(CLI_VERSION)'
        path: $(NPM_GLOBAL_CACHE)

    - script: |
        set -euo pipefail
        export PATH="$HOME/.npm-global/bin:$PATH"
        echo "##vso[task.prependpath]$HOME/.npm-global/bin"
        if ! command -v uip >/dev/null; then
          npm install -g "@uipath/cli@$(CLI_VERSION)"
        fi

        uip login \
          --client-id env.UIPATH_CLIENT_ID \
          --client-secret env.UIPATH_CLIENT_SECRET \
          --tenant "$UIPATH_TENANT"
      displayName: 'Install + authenticate'
      env:
        UIPATH_CLIENT_ID:     $(UIPATH_CLIENT_ID)
        UIPATH_CLIENT_SECRET: $(UIPATH_CLIENT_SECRET)
        UIPATH_TENANT:        $(UIPATH_TENANT)

    - script: |
        set -euo pipefail

        EXECUTION_ID=$(uip tm testsets run \
          --test-set-key "$TEST_SET_KEY" \
          --output-filter "Data.ExecutionId" \
          --output plain)

        echo "started execution $EXECUTION_ID"

        if ! uip tm wait \
          --execution-id "$EXECUTION_ID" \
          --project-key "$PROJECT_KEY" \
          --timeout 1800; then
          code=$?
          case "$code" in
            2) echo "##vso[task.logissue type=error]test run did not finish within 30 minutes"; exit 2 ;;
            *) echo "##vso[task.logissue type=error]wait failed (exit $code)"; exit "$code" ;;
          esac
        fi

        uip tm report get \
          --execution-id "$EXECUTION_ID" \
          --project-key "$PROJECT_KEY"

        FAILED=$(uip tm report get \
          --execution-id "$EXECUTION_ID" \
          --project-key "$PROJECT_KEY" \
          --output-filter "Data.Failed" \
          --output plain)

        if [ "$FAILED" -gt 0 ]; then
          echo "##vso[task.logissue type=error]$FAILED test case(s) failed"
          exit 1
        fi
      displayName: 'Launch, wait, verify'
      env:
        TEST_SET_KEY: $(TEST_SET_KEY)
        PROJECT_KEY:  $(PROJECT_KEY)
```

## Walkthrough

### Stage 1 — Build

- **`NodeTool@0`** pins Node.js to version 20. Pin to a major; the CLI requires 18+.
- **`Cache@2`** caches the npm global `node_modules` directory. The cache key includes `$(CLI_VERSION)` so a version bump invalidates cleanly.
- **Install step** switches npm's global prefix to `$HOME/.npm-global` (no `sudo` needed), prepends it to the PATH, and — only if the cache missed — installs `@uipath/cli`. The first invocation of a tool command (`uip solution …`, `uip tm …`) auto-installs that tool, so no explicit `uip tools install` is needed. See [Installing UiPath CLI — CI/CD](./installing-uipath-cli.md#installing-in-cicd) and [Scripting patterns — pinning versions](./scripting-patterns.md#pinning-versions-in-ci).
- **Pack step** invokes [`uip solution pack`](./uip-solution-pack.md) with an explicit `--version` (never rely on the `1.0.0` default in CI).
- **`publish: ...`** uploads the entire `$(OUTPUT_DIR)` as a pipeline artifact named `solution-zip` so the Deploy stage can consume it. Publishing the directory rather than a hardcoded filename keeps the recipe stable across CLI naming-convention changes.

### Stage 2 — Deploy

Uses a [**deployment job**](https://learn.microsoft.com/azure/devops/pipelines/process/deployment-jobs) bound to a named environment. Environments are where you configure gates and approvals in Azure DevOps — the YAML itself stays simple.

- **Re-installs the CLI** (the cache should hit on the second stage of the same run).
- **`uip login`** uses the `env.VAR_NAME` prefix for both the client ID and secret. The `env:` block at the step level maps `$(UIPATH_CLIENT_ID)` (from the variable group) to the actual environment variable `UIPATH_CLIENT_ID` that the flag resolves. **Do not** pass the secret literally on the command line (`--client-secret "$(UIPATH_CLIENT_SECRET)"`) — that leaks it into the build log and `ps` output. The `env.` prefix feature is documented in [Authentication — the env.VAR_NAME prefix](./authentication.md#the-envvar_name-prefix).
- **`download: current`** pulls the `solution-zip` artifact from Stage 1 into `$(Pipeline.Workspace)/solution-zip`.
- **`uip solution publish`** uploads the `.zip` to the tenant feed.
- **`uip solution deploy run`** creates the deployment. `--name` uses `$(Build.BuildId)` so each run is identifiable and re-deploys do not clobber each other's deployment record.

### Stage 3 — Test

Only runs if `TEST_SET_KEY` is set at the variable-group level — the `condition:` guard skips the stage cleanly if you don't have a test suite configured.

The shell block is the canonical **launch → wait → verify** flow from [How-to: run tests from the CLI](./howto-run-tests.md):

1. [`uip tm testsets run`](./uip-test-manager-testsets.md#uip-tm-testsets-run) launches the run and returns an `ExecutionId`.
2. [`uip tm wait`](./uip-test-manager-wait.md) blocks until the execution reaches a terminal state (exit `2` means timeout, not auth failure — a domain-specific reuse of the exit-code slot).
3. [`uip tm report get`](./uip-test-manager-report.md) reads `Data.Failed`; the step fails with `##vso[task.logissue type=error]` so the outcome surfaces cleanly in the Azure DevOps UI.

## Common variations

### Promote across environments

Link two variable groups — `uipath-stage` and `uipath-prod` — and turn each into a separate `deployment:` job. Use environment-specific gates/approvals to decide when prod runs:

```yaml
- stage: DeployStage
  dependsOn: Build
  jobs:
  - deployment: deploy-stage
    variables: [ { group: uipath-stage } ]
    environment: 'uipath-stage'
    # …same steps as above, but with stage's tenant

- stage: DeployProd
  dependsOn: DeployStage
  jobs:
  - deployment: deploy-prod
    variables: [ { group: uipath-prod } ]
    environment: 'uipath-prod'    # attach a manual approval gate here
    # …same steps as above
```

Deeper coverage in [How-to: pack and publish a Solution — promote one package across tenants](./howto-pack-publish-solution.md#promote-one-package-across-tenants).

### Rollback

Azure DevOps does not roll back automatically; add a manual stage that re-deploys the previous version:

```yaml
- stage: Rollback
  dependsOn: []                   # run on demand, not from Deploy
  jobs:
  - job: rollback
    pool: { vmImage: 'ubuntu-latest' }
    steps:
    # …install + auth…
    - script: |
        uip solution deploy run \
          --name "$(SOLUTION_NAME)-rollback" \
          --package-name "$(SOLUTION_NAME)" \
          --package-version "$(ROLLBACK_VERSION)" \
          --folder-name MySolution \
          --folder-path Shared
      displayName: 'Re-deploy previous version'
```

Pass `ROLLBACK_VERSION` at queue time. For destructive rollback (uninstall + delete artifact), see [How-to: pack and publish a Solution — rollback](./howto-pack-publish-solution.md#rollback).

### Skipping tests

To run without the test stage, delete the `Test` stage block (or leave `TEST_SET_KEY` unset — the `condition:` on the stage will skip it).

## Common pitfalls

- **`env.` prefix vs literal secret.** Always `--client-secret env.UIPATH_CLIENT_SECRET`, never `--client-secret "$(UIPATH_CLIENT_SECRET)"`. The literal form embeds the value into the rendered command line. See the [auth warning](./authentication.md#flow-2-external-application-client-credentials).
- **`##vso[task.setvariable]` is not needed** for `env.` resolution — `env:` on the step exposes the variable directly.
- **Multi-line bash needs `set -euo pipefail`** at the top of every `script:` block. Without it, `pack` can fail while later steps keep running. See [Scripting patterns — strict shell options](./scripting-patterns.md#strict-shell-options).
- **Cache hits are not guaranteed.** Always wrap the install commands in `if ! command -v uip` so the pipeline self-heals when the cache is cold.

## See also

- [How-to: deploy to Orchestrator from CI](./howto-deploy-from-ci.md) — platform-agnostic guidance.
- [How-to: pack and publish a Solution](./howto-pack-publish-solution.md) — versioning and rollback.
- [How-to: run tests from the CLI](./howto-run-tests.md) — the launch → wait → verify pattern in depth.
- [CI/CD recipe: GitHub Actions](./recipes-github-actions.md), [Jenkins](./recipes-jenkins.md), [GitLab CI](./recipes-gitlab.md) — the same pipeline in other platforms.
