Skip to main content

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"
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/*.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"

ARTIFACT=$(find "$OUTPUT_DIR" -maxdepth 1 -name "*.zip" | head -1)
uip solution publish "$ARTIFACT"

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 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 "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 CLI install. Tools auto-install on first use, so the anchor only handles the host.
  • 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. The path is a glob ($OUTPUT_DIR/*.zip) so it picks up whatever filename uip solution pack produces. 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

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_PROD…

Deeper coverage in How-to: pack and publish a Solution — promote one package across tenants.

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

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