# 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.

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](./howto-deploy-from-ci.md). 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](./authentication.md#flow-2-external-application-client-credentials).
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

```yaml
# -----------------------------------------------------------------------------
# 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`](./uip-solution-pack.md).
- **`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](./authentication.md#the-envvar_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](./howto-run-tests.md). Exit `2` from [`uip tm wait`](./uip-test-manager-wait.md) 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:

```yaml
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](./howto-pack-publish-solution.md#promote-one-package-across-tenants).

### Rollback

Trigger manually via a separate job with a pipeline variable:

```yaml
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](./howto-pack-publish-solution.md#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](./scripting-patterns.md#strict-shell-options).

## 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.
- [CI/CD recipe: Azure Pipelines](./recipes-azure-devops.md), [GitHub Actions](./recipes-github-actions.md), [Jenkins](./recipes-jenkins.md) — the same pipeline in other platforms.
