Skip to main content

CI/CD recipe: GitHub Actions

This page gives you a complete .github/workflows/deploy.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. Drop it into your repo, add two secrets, 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 GitHub Actions 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.
  2. Store the secrets in the repository (or organization/environment) settings:
    • Settings → Secrets and variables → Actions → New repository secret.
    • Add UIPATH_CLIENT_ID and UIPATH_CLIENT_SECRET as secrets.
    • Add UIPATH_TENANT as a variable (not a secret — it is not sensitive).
  3. Provision a Test Manager project and test set if you want the test job. Put TEST_SET_KEY (e.g. PROJECT:42) and PROJECT_KEY in the repo variables. See How-to: run tests from the CLI.

.github/workflows/deploy.yml

name: Deploy UiPath Solution

on:
push:
branches: [ main ]
pull_request:
branches: [ main ]
workflow_dispatch:

env:
CLI_VERSION: '1.0.0'
NODE_VERSION: '20'
SOLUTION_NAME: 'my-solution'
SOLUTION_DIR: './my-solution'
OUTPUT_DIR: './dist'

jobs:

build:
name: Pack Solution
runs-on: ubuntu-latest
outputs:
solution_version: ${{ steps.version.outputs.version }}
steps:

- uses: actions/checkout@v4

- name: Compute version
id: version
run: echo "version=1.2.0-ci.${{ github.run_number }}" >> "$GITHUB_OUTPUT"

- uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}

- name: Cache npm global (@uipath/cli)
uses: actions/cache@v4
with:
path: ~/.npm-global/lib/node_modules
key: uip-${{ env.CLI_VERSION }}-${{ runner.os }}

- name: Install UiPath CLI
shell: bash
run: |
set -euo pipefail
mkdir -p "$HOME/.npm-global"
npm config set prefix "$HOME/.npm-global"
echo "$HOME/.npm-global/bin" >> "$GITHUB_PATH"
export PATH="$HOME/.npm-global/bin:$PATH"

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

- name: Pack Solution
id: pack
shell: bash
run: |
set -euo pipefail
mkdir -p "$OUTPUT_DIR"
uip solution pack "$SOLUTION_DIR" "$OUTPUT_DIR" \
--name "$SOLUTION_NAME" \
--version "${{ steps.version.outputs.version }}"
ARTIFACT=$(find "$OUTPUT_DIR" -maxdepth 1 -name "*.zip" | head -1)
echo "artifact=$ARTIFACT" >> "$GITHUB_OUTPUT"

- uses: actions/upload-artifact@v4
with:
name: solution-zip
path: ${{ steps.pack.outputs.artifact }}

deploy:
name: Publish and deploy
needs: build
runs-on: ubuntu-latest
environment: uipath-prod # attach approval gates here if needed
steps:

- uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}

- name: Cache npm global (@uipath/cli)
uses: actions/cache@v4
with:
path: ~/.npm-global/lib/node_modules
key: uip-${{ env.CLI_VERSION }}-${{ runner.os }}

- name: Install UiPath CLI
shell: bash
run: |
set -euo pipefail
mkdir -p "$HOME/.npm-global"
npm config set prefix "$HOME/.npm-global"
echo "$HOME/.npm-global/bin" >> "$GITHUB_PATH"
export PATH="$HOME/.npm-global/bin:$PATH"

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

- uses: actions/download-artifact@v4
with:
name: solution-zip
path: ${{ env.OUTPUT_DIR }}

- name: Authenticate
shell: bash
env:
UIPATH_CLIENT_ID: ${{ secrets.UIPATH_CLIENT_ID }}
UIPATH_CLIENT_SECRET: ${{ secrets.UIPATH_CLIENT_SECRET }}
UIPATH_TENANT: ${{ vars.UIPATH_TENANT }}
run: |
set -euo pipefail
uip login \
--client-id env.UIPATH_CLIENT_ID \
--client-secret env.UIPATH_CLIENT_SECRET \
--tenant "$UIPATH_TENANT"

- name: Publish to tenant feed
shell: bash
run: |
set -euo pipefail
ARTIFACT=$(find "$OUTPUT_DIR" -maxdepth 1 -name "*.zip" | head -1)
uip solution publish "$ARTIFACT"

- name: Deploy to Orchestrator
shell: bash
run: |
set -euo pipefail
uip solution deploy run \
--name "${SOLUTION_NAME}-${{ github.run_number }}" \
--package-name "$SOLUTION_NAME" \
--package-version "${{ needs.build.outputs.solution_version }}" \
--folder-name MySolution \
--folder-path Shared

test:
name: Run Test Manager suite
needs: deploy
if: ${{ vars.TEST_SET_KEY != '' }}
runs-on: ubuntu-latest
steps:

- uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}

- name: Cache npm global (@uipath/cli)
uses: actions/cache@v4
with:
path: ~/.npm-global/lib/node_modules
key: uip-${{ env.CLI_VERSION }}-${{ runner.os }}

- name: Install CLI and authenticate
shell: bash
env:
UIPATH_CLIENT_ID: ${{ secrets.UIPATH_CLIENT_ID }}
UIPATH_CLIENT_SECRET: ${{ secrets.UIPATH_CLIENT_SECRET }}
UIPATH_TENANT: ${{ vars.UIPATH_TENANT }}
run: |
set -euo pipefail
mkdir -p "$HOME/.npm-global"
npm config set prefix "$HOME/.npm-global"
echo "$HOME/.npm-global/bin" >> "$GITHUB_PATH"
export PATH="$HOME/.npm-global/bin:$PATH"

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"

- name: Launch, wait, verify
shell: bash
env:
TEST_SET_KEY: ${{ vars.TEST_SET_KEY }}
PROJECT_KEY: ${{ vars.PROJECT_KEY }}
run: |
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 "::error::test run did not finish within 30 minutes"; exit 2 ;;
*) echo "::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 "::error::$FAILED test case(s) failed"
exit 1
fi

Walkthrough

build job

  • actions/setup-node@v4 pins Node to the major version specified by NODE_VERSION. The CLI requires Node 18+.
  • actions/cache@v4 caches the npm global node_modules directory, keyed on the pinned CLI version and the runner OS. When the cache hits, the install step's if ! command -v uip guard turns into a no-op.
  • Install step configures a user-local npm prefix (no sudo needed on GitHub-hosted runners), adds it to GITHUB_PATH so later steps see the uip command, and installs the CLI. The first invocation of a tool command (uip solution …, uip tm …) auto-installs that tool — no explicit uip tools install is needed. See Installing UiPath CLI — CI/CD.
  • Pack step invokes uip solution pack with an explicit --version built from github.run_number (monotonic, unique per run in the repo). The step captures the produced .zip path with find rather than hardcoding the filename — keeps the recipe stable across CLI naming-convention changes.
  • actions/upload-artifact@v4 uploads the captured .zip so the deploy job can download it in the next run.
  • outputs.solution_version propagates the computed version to the deploy job — the simplest way to share a value between jobs in the same workflow.

deploy job

  • Re-installs the CLIneeds: build forces the job order, but each job runs on a fresh runner. The cache should hit on the second install.
  • actions/download-artifact@v4 pulls the solution-zip into $OUTPUT_DIR.
  • Authenticate step passes secrets via the step's env: block, then uip login reads them through the env. prefix. The env.VAR_NAME prefix is the supported way to keep a secret out of the command line — see Authentication — the env.VAR_NAME prefix. Do not write --client-secret "${{ secrets.UIPATH_CLIENT_SECRET }}" — that embeds the value into the rendered command and into the step log.
  • Publish + deploy steps use uip solution publish and uip solution deploy run. --name uses github.run_number so each deployment is identifiable.
  • environment: uipath-prod ties the job to a deployment environment. Environments are where you configure required reviewers, wait timers, and deployment branches — keep them in the UI and leave the YAML declarative.

test job

Runs only if the TEST_SET_KEY repo variable is set — the if: guard skips it otherwise. The shell block is the canonical launch → wait → verify pattern from How-to: run tests from the CLI:

  1. uip tm testsets run launches and returns an ExecutionId.
  2. uip tm wait blocks until terminal state. Exit 2 means timeout on wait (not auth failure); exit code is reported via ::error:: so it surfaces in the Actions UI.
  3. uip tm report get reads Data.Failed and the step exits 1 when anything failed.

Common variations

Promote across environments

Add a second deploy job that depends on the first, with different secrets:

  deploy-stage:
needs: build
environment: uipath-stage
# …same steps as `deploy`, using ${{ secrets.UIPATH_STAGE_CLIENT_ID }} etc.

deploy-prod:
needs: deploy-stage
environment: uipath-prod # add "Required reviewers" in the environment settings
# …same steps as `deploy`, using ${{ secrets.UIPATH_PROD_CLIENT_ID }} etc.

Approval gates live in the environment settings — the needs: chain enforces order. See How-to: pack and publish a Solution — promote one package across tenants.

Rollback

Add a workflow_dispatch input and a guarded job that re-deploys a specific version:

on:
workflow_dispatch:
inputs:
rollback_version:
description: 'Version to roll back to (e.g. 1.1.9)'
required: false
type: string

jobs:
rollback:
if: ${{ github.event.inputs.rollback_version != '' }}
runs-on: ubuntu-latest
# …install + auth…
steps:
- name: Re-deploy previous version
run: |
uip solution deploy run \
--name "${SOLUTION_NAME}-rollback" \
--package-name "$SOLUTION_NAME" \
--package-version "${{ github.event.inputs.rollback_version }}" \
--folder-name MySolution \
--folder-path Shared

Trigger with Actions → Deploy UiPath Solution → Run workflow and enter the rollback version. For destructive rollback (uninstall + solution packages delete), see How-to: pack and publish a Solution — rollback.

Skip tests

Set (or leave unset) the TEST_SET_KEY repo variable. The if: ${{ vars.TEST_SET_KEY != '' }} guard skips the whole job.

Common pitfalls

  • ${{ secrets.X }} interpolation. It substitutes at the step-rendering layer. If you put a secret into a run: command line directly, the value becomes part of the rendered script — and the step log, unless GitHub masks it. Always route secrets through env: and read them with env.VAR_NAME inside uip.
  • $GITHUB_PATH vs $PATH. Exporting PATH=… in one step does not carry to the next. Use echo "…" >> "$GITHUB_PATH" to persist a PATH addition across steps on the same runner.
  • strict shell options. Start every multi-line run: with set -euo pipefail — without it, a failed uip solution pack can be followed by a "successful" publish of a stale artifact. See Scripting patterns — strict shell options.
  • Cache false-positives. If you bump CLI_VERSION but the cache key does not include it, you will keep using the old CLI. The key in this YAML includes ${{ env.CLI_VERSION }} exactly for this reason.
  • Cache path is coupled to the npm prefix. The cache block uses ~/.npm-global/lib/node_modules, which only works because the install step runs npm config set prefix "$HOME/.npm-global". If you change the prefix (e.g. on a self-hosted Windows runner where the convention is %APPDATA%\npm\node_modules), both the cache path: and the npm config set prefix line must move together. On a non-ubuntu-latest runner, dump the real path first with - run: npm root -g and mirror whatever it reports.

See also