UiPath Documentation
uipath-cli
latest
false

UiPath CLI user guide

Last updated May 7, 2026

CI/CD recipe: GitLab CI

This page gives you a complete .gitlab-ci.yml that installs the CLI, authenticates with an External Application, packs and publishes a UiPath Solution, deploys it to Orchestrator across multiple tenants (via a matrix), and runs a Test Manager suite. Drop it into the root of your repo, set three CI/CD variables, and it runs.

For the underlying principles — auth, caching, tool pre-install, version pinning — see How-to: deploy to Orchestrator from CI. This page focuses on the GitLab syntax, including the features (cache with a keyed scope, parallel: matrix) that are GitLab-specific.

Prerequisites

Before copying the YAML below:

  1. Create an External Application in UiPath with the OR.* scopes your pipeline needs. See Authentication — Flow 2.
  2. Store the secrets as CI/CD variables:
    • Project → Settings → CI/CD → Variables.
    • Add UIPATH_CLIENT_ID and UIPATH_CLIENT_SECRET. Mark both as Masked and Protected (so they only expose on protected branches / tags).
    • Add UIPATH_TENANT_DEV, UIPATH_TENANT_STAGE, UIPATH_TENANT_PROD with the tenant names (not masked — tenant names are not sensitive).
  3. Provision a Test Manager project and test set if you want the test job. Add TEST_SET_KEY and PROJECT_KEY as regular variables.

.gitlab-ci.yml

# -----------------------------------------------------------------------------
# Deploy UiPath Solution
# -----------------------------------------------------------------------------
# Auth: External Application, env.VAR_NAME prefix (never the literal value).
# Cache: npm global node_modules, keyed by CLI version.
# Matrix: deploy job fans out across dev / stage / prod tenants.
# -----------------------------------------------------------------------------

image: node:20

stages:
  - build
  - deploy
  - test

variables:
  CLI_VERSION: '1.0.0'
  SOLUTION_NAME: 'my-solution'
  SOLUTION_DIR:  './my-solution'
  OUTPUT_DIR:    './dist'
  SOLUTION_VERSION: '1.2.0-ci.$CI_PIPELINE_IID'

  # Workspace-local npm prefix so installs need no sudo and are cacheable.
  NPM_PREFIX: "$CI_PROJECT_DIR/.npm-global"

# Re-usable install block. GitLab does not have anchors for script:; we use
# YAML anchors on a hidden job and extend from it.
.install-uip: &install-uip |
  set -euo pipefail
  mkdir -p "$NPM_PREFIX"
  npm config set prefix "$NPM_PREFIX"
  export PATH="$NPM_PREFIX/bin:$PATH"

  if ! command -v uip >/dev/null; then
    npm install -g "@uipath/cli@$CLI_VERSION"
    uip tools install \
      @uipath/orchestrator-tool \
      @uipath/solution-tool \
      @uipath/test-manager-tool
  fi
  uip --version

cache:
  key: "uip-$CLI_VERSION"
  paths:
    - .npm-global/lib/node_modules
  policy: pull-push

# -----------------------------------------------------------------------------
# Stage: build
# -----------------------------------------------------------------------------

pack:
  stage: build
  script:
    - *install-uip
    - mkdir -p "$OUTPUT_DIR"
    - |
      uip solution pack "$SOLUTION_DIR" "$OUTPUT_DIR" \
        --name "$SOLUTION_NAME" \
        --version "$SOLUTION_VERSION"
  artifacts:
    paths:
      - "$OUTPUT_DIR/$SOLUTION_NAME.$SOLUTION_VERSION.zip"
    expire_in: 30 days

# -----------------------------------------------------------------------------
# Stage: deploy — matrix across environments
# -----------------------------------------------------------------------------

deploy:
  stage: deploy
  needs:
    - job: pack
      artifacts: true
  parallel:
    matrix:
      - ENVIRONMENT: dev
        TENANT_VAR:  UIPATH_TENANT_DEV
      - ENVIRONMENT: stage
        TENANT_VAR:  UIPATH_TENANT_STAGE
      - ENVIRONMENT: prod
        TENANT_VAR:  UIPATH_TENANT_PROD
  environment:
    name: uipath/$ENVIRONMENT
  rules:
    # Prod only on protected branches — set protection under Settings → Repository.
    - if: '$ENVIRONMENT == "prod" && $CI_COMMIT_REF_PROTECTED != "true"'
      when: never
    - when: on_success
  script:
    - *install-uip
    - |
      # Resolve the per-environment tenant from the matrix variable.
      UIPATH_TENANT="${!TENANT_VAR}"
      if [ -z "$UIPATH_TENANT" ]; then
        echo "Tenant variable $TENANT_VAR is empty; set it in CI/CD settings." >&2
        exit 3
      fi

      uip login \
        --client-id env.UIPATH_CLIENT_ID \
        --client-secret env.UIPATH_CLIENT_SECRET \
        --tenant "$UIPATH_TENANT"

      uip solution publish "$OUTPUT_DIR/$SOLUTION_NAME.$SOLUTION_VERSION.zip"

      uip solution deploy run \
        --name "$SOLUTION_NAME-$ENVIRONMENT-$CI_PIPELINE_IID" \
        --package-name "$SOLUTION_NAME" \
        --package-version "$SOLUTION_VERSION" \
        --folder-name MySolution \
        --folder-path Shared

# -----------------------------------------------------------------------------
# Stage: test
# -----------------------------------------------------------------------------

test:
  stage: test
  needs:
    - job: "deploy: [dev, UIPATH_TENANT_DEV]"   # depend on the dev leg of the matrix
      optional: true
  rules:
    - if: '$TEST_SET_KEY == null || $TEST_SET_KEY == ""'
      when: never
    - when: on_success
  script:
    - *install-uip
    - |
      uip login \
        --client-id env.UIPATH_CLIENT_ID \
        --client-secret env.UIPATH_CLIENT_SECRET \
        --tenant "$UIPATH_TENANT_DEV"

      EXECUTION_ID=$(uip tm testset execute \
        --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 "test run did not finish within 30 minutes" >&2; exit 2 ;;
          *) echo "wait failed (exit $code)" >&2; 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 "$FAILED test case(s) failed" >&2
        exit 1
      fi
# -----------------------------------------------------------------------------
# Deploy UiPath Solution
# -----------------------------------------------------------------------------
# Auth: External Application, env.VAR_NAME prefix (never the literal value).
# Cache: npm global node_modules, keyed by CLI version.
# Matrix: deploy job fans out across dev / stage / prod tenants.
# -----------------------------------------------------------------------------

image: node:20

stages:
  - build
  - deploy
  - test

variables:
  CLI_VERSION: '1.0.0'
  SOLUTION_NAME: 'my-solution'
  SOLUTION_DIR:  './my-solution'
  OUTPUT_DIR:    './dist'
  SOLUTION_VERSION: '1.2.0-ci.$CI_PIPELINE_IID'

  # Workspace-local npm prefix so installs need no sudo and are cacheable.
  NPM_PREFIX: "$CI_PROJECT_DIR/.npm-global"

# Re-usable install block. GitLab does not have anchors for script:; we use
# YAML anchors on a hidden job and extend from it.
.install-uip: &install-uip |
  set -euo pipefail
  mkdir -p "$NPM_PREFIX"
  npm config set prefix "$NPM_PREFIX"
  export PATH="$NPM_PREFIX/bin:$PATH"

  if ! command -v uip >/dev/null; then
    npm install -g "@uipath/cli@$CLI_VERSION"
    uip tools install \
      @uipath/orchestrator-tool \
      @uipath/solution-tool \
      @uipath/test-manager-tool
  fi
  uip --version

cache:
  key: "uip-$CLI_VERSION"
  paths:
    - .npm-global/lib/node_modules
  policy: pull-push

# -----------------------------------------------------------------------------
# Stage: build
# -----------------------------------------------------------------------------

pack:
  stage: build
  script:
    - *install-uip
    - mkdir -p "$OUTPUT_DIR"
    - |
      uip solution pack "$SOLUTION_DIR" "$OUTPUT_DIR" \
        --name "$SOLUTION_NAME" \
        --version "$SOLUTION_VERSION"
  artifacts:
    paths:
      - "$OUTPUT_DIR/$SOLUTION_NAME.$SOLUTION_VERSION.zip"
    expire_in: 30 days

# -----------------------------------------------------------------------------
# Stage: deploy — matrix across environments
# -----------------------------------------------------------------------------

deploy:
  stage: deploy
  needs:
    - job: pack
      artifacts: true
  parallel:
    matrix:
      - ENVIRONMENT: dev
        TENANT_VAR:  UIPATH_TENANT_DEV
      - ENVIRONMENT: stage
        TENANT_VAR:  UIPATH_TENANT_STAGE
      - ENVIRONMENT: prod
        TENANT_VAR:  UIPATH_TENANT_PROD
  environment:
    name: uipath/$ENVIRONMENT
  rules:
    # Prod only on protected branches — set protection under Settings → Repository.
    - if: '$ENVIRONMENT == "prod" && $CI_COMMIT_REF_PROTECTED != "true"'
      when: never
    - when: on_success
  script:
    - *install-uip
    - |
      # Resolve the per-environment tenant from the matrix variable.
      UIPATH_TENANT="${!TENANT_VAR}"
      if [ -z "$UIPATH_TENANT" ]; then
        echo "Tenant variable $TENANT_VAR is empty; set it in CI/CD settings." >&2
        exit 3
      fi

      uip login \
        --client-id env.UIPATH_CLIENT_ID \
        --client-secret env.UIPATH_CLIENT_SECRET \
        --tenant "$UIPATH_TENANT"

      uip solution publish "$OUTPUT_DIR/$SOLUTION_NAME.$SOLUTION_VERSION.zip"

      uip solution deploy run \
        --name "$SOLUTION_NAME-$ENVIRONMENT-$CI_PIPELINE_IID" \
        --package-name "$SOLUTION_NAME" \
        --package-version "$SOLUTION_VERSION" \
        --folder-name MySolution \
        --folder-path Shared

# -----------------------------------------------------------------------------
# Stage: test
# -----------------------------------------------------------------------------

test:
  stage: test
  needs:
    - job: "deploy: [dev, UIPATH_TENANT_DEV]"   # depend on the dev leg of the matrix
      optional: true
  rules:
    - if: '$TEST_SET_KEY == null || $TEST_SET_KEY == ""'
      when: never
    - when: on_success
  script:
    - *install-uip
    - |
      uip login \
        --client-id env.UIPATH_CLIENT_ID \
        --client-secret env.UIPATH_CLIENT_SECRET \
        --tenant "$UIPATH_TENANT_DEV"

      EXECUTION_ID=$(uip tm testset execute \
        --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 "test run did not finish within 30 minutes" >&2; exit 2 ;;
          *) echo "wait failed (exit $code)" >&2; 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 "$FAILED test case(s) failed" >&2
        exit 1
      fi

Walkthrough

Top-level setup

  • image: node:20 — every job runs in the official Node.js 20 image. The CLI requires Node 18+. If your GitLab runner already has Node installed and you don't need a container, you can remove this and use a shell executor instead.
  • variables: — the pipeline-wide values. SOLUTION_VERSION interpolates $CI_PIPELINE_IID (the incremental, project-scoped pipeline number — better for versioning than $CI_JOB_ID, which is global and non-monotonic).
  • .install-uip anchor — GitLab does not let you anchor script: blocks directly, but you can anchor a YAML node containing a shell string and splice it in with - *install-uip. Same install guard as the other recipes: workspace-local prefix, conditional install, pre-installed tools.
  • cache: — the key uip-$CLI_VERSION ensures a CLI version bump invalidates the cache cleanly. policy: pull-push reads on entry and writes on successful job exit. If you run at scale and want to shave seconds off every job, split into a dedicated "seed the cache" job that runs pull-push and have all other jobs use policy: pull only.

pack job

  • Installs the CLI via the shared anchor.
  • uip solution pack with an explicit version — see uip solution pack.
  • artifacts: carries the .zip to the next stage. expire_in: 30 days prevents GitLab's artifact storage from growing unbounded; bump it if you need longer traceability.

deploy job with parallel: matrix

The matrix expands into three jobs — deploy: [dev, UIPATH_TENANT_DEV], deploy: [stage, UIPATH_TENANT_STAGE], deploy: [prod, UIPATH_TENANT_PROD] — that run in parallel. Each gets a different $ENVIRONMENT and $TENANT_VAR, and uses bash indirect expansion (${!TENANT_VAR}) to read the per-environment tenant from the right CI/CD variable.

  • environment: name: uipath/$ENVIRONMENT — GitLab tracks deployments in its Environments view, so every tenant gets a per-environment history with rollback buttons.
  • rules: — the first rule blocks prod from non-protected branches. Combined with Settings → Repository → Protected branches (where you mark main protected), this is how you stop a feature branch from accidentally deploying to production. The UIPATH_CLIENT_* variables should also be marked Protected so they only resolve on protected refs.
  • uip login --client-id env.UIPATH_CLIENT_ID --client-secret env.UIPATH_CLIENT_SECRET — the env.VAR_NAME prefix is the supported way to pass a secret to the CLI without it ever appearing in the shell command line. GitLab masks variables in logs when marked Masked, but the env. prefix is a defense-in-depth anyway. See Authentication — the env.VAR_NAME prefix.
  • Deployment name$SOLUTION_NAME-$ENVIRONMENT-$CI_PIPELINE_IID makes each deploy traceable to a specific pipeline run and environment.
Note:

In parallel-matrix jobs, if one leg fails, the others keep running by default. If you want prod to wait for dev and stage, turn the matrix into three sequential jobs (or use needs: between them) instead.

test job

  • needs: references the matrix leg by its expanded name — "deploy: [dev, UIPATH_TENANT_DEV]". The optional: true makes the dependency non-fatal if the dev leg was skipped by the rules: block.
  • rules: skips the job when TEST_SET_KEY is unset, same pattern as the other recipes.
  • Launch → wait → verify — the canonical test pattern from How-to: run tests from the CLI. Exit 2 from uip tm wait means timeout (not auth failure).

Common variations

Pin tool versions too

For patch-level reproducibility, replace the install anchor with pinned versions:

.install-uip: &install-uip |
  # …same prefix setup…
  if ! command -v uip >/dev/null; then
    npm install -g "@uipath/cli@$CLI_VERSION"
    uip tools install \
      @uipath/orchestrator-tool@1.0.2 \
      @uipath/solution-tool@1.0.2 \
      @uipath/test-manager-tool@1.0.2
  fi
.install-uip: &install-uip |
  # …same prefix setup…
  if ! command -v uip >/dev/null; then
    npm install -g "@uipath/cli@$CLI_VERSION"
    uip tools install \
      @uipath/orchestrator-tool@1.0.2 \
      @uipath/solution-tool@1.0.2 \
      @uipath/test-manager-tool@1.0.2
  fi

Serial promotion with manual gate

If you want prod to require a manual click rather than protected-branch gating, split the matrix into three jobs and add when: manual to the prod one:

deploy-dev:
  stage: deploy
  # …as deploy above, fixed to UIPATH_TENANT_DEV…

deploy-stage:
  stage: deploy
  needs: [ pack, deploy-dev ]
  # …as deploy above, fixed to UIPATH_TENANT_STAGE…

deploy-prod:
  stage: deploy
  needs: [ pack, deploy-stage ]
  when: manual                    # requires a reviewer to click "Play"
  allow_failure: false
  environment:
    name: uipath/prod
  # …as deploy above, fixed to UIPATH_TENANT_PRODdeploy-dev:
  stage: deploy
  # …as deploy above, fixed to UIPATH_TENANT_DEV…

deploy-stage:
  stage: deploy
  needs: [ pack, deploy-dev ]
  # …as deploy above, fixed to UIPATH_TENANT_STAGE…

deploy-prod:
  stage: deploy
  needs: [ pack, deploy-stage ]
  when: manual                    # requires a reviewer to click "Play"
  allow_failure: false
  environment:
    name: uipath/prod
  # …as deploy above, fixed to UIPATH_TENANT_PROD…

Rollback

Trigger manually via a separate job with a pipeline variable:

rollback:
  stage: deploy
  when: manual
  rules:
    - if: '$ROLLBACK_VERSION != null && $ROLLBACK_VERSION != ""'
  script:
    - *install-uip
    - |
      uip login \
        --client-id env.UIPATH_CLIENT_ID \
        --client-secret env.UIPATH_CLIENT_SECRET \
        --tenant "$UIPATH_TENANT_PROD"

      uip solution deploy run \
        --name "$SOLUTION_NAME-rollback" \
        --package-name "$SOLUTION_NAME" \
        --package-version "$ROLLBACK_VERSION" \
        --folder-name MySolution \
        --folder-path Shared
rollback:
  stage: deploy
  when: manual
  rules:
    - if: '$ROLLBACK_VERSION != null && $ROLLBACK_VERSION != ""'
  script:
    - *install-uip
    - |
      uip login \
        --client-id env.UIPATH_CLIENT_ID \
        --client-secret env.UIPATH_CLIENT_SECRET \
        --tenant "$UIPATH_TENANT_PROD"

      uip solution deploy run \
        --name "$SOLUTION_NAME-rollback" \
        --package-name "$SOLUTION_NAME" \
        --package-version "$ROLLBACK_VERSION" \
        --folder-name MySolution \
        --folder-path Shared

Start the pipeline from CI/CD → Pipelines → Run pipeline and set ROLLBACK_VERSION to the target version (for example, 1.1.9). For destructive rollback (uninstall + delete artifact), see How-to: pack and publish a Solution — rollback.

Skip tests

Leave TEST_SET_KEY unset in the CI/CD variables. The rules: block on the test job skips it cleanly.

Common pitfalls

  • Masked != Protected. A masked variable hides the value in logs but is still available on all branches (including short-lived feature branches). A protected variable only exposes on protected refs. You want both for auth secrets — otherwise a pushed feature branch could run uip login against prod.
  • Indirect expansion needs bash. ${!TENANT_VAR} is a bash feature; the default sh in some minimal images doesn't support it. The node:20 image includes bash by default; on alpine-based images, add apk add bash or switch to a case statement over explicit per-environment variables.
  • Matrix job names contain spaces. needs: - job: "deploy: [dev, UIPATH_TENANT_DEV]" — the name includes : [, so quote it in YAML.
  • Cache paths are workspace-relative. The cache entry .npm-global/lib/node_modules works because NPM_PREFIX="$CI_PROJECT_DIR/.npm-global" puts it inside the workspace. If you move the prefix outside, the cache stops working.
  • set -euo pipefail must be at the top of every multi-line script. Without it, a failing pack can be followed by a "successful" publish of a stale artifact. See Scripting patterns — strict shell options.

See also

Was this page helpful?

Connect

Need help? Support

Want to learn? UiPath Academy

Have questions? UiPath Forum

Stay updated