UniteLabs
Guides

CI/CD for workflows

Automate workflow deploys to UniteLabs with three channels — DEV (manual), STG (push to main), PRD (per-workflow release tags) — on GitHub Actions or GitLab CI.

The workflow template ships CI for both GitHub Actions and GitLab CI out of the box. Both wire scripts/deploy.py (see Deploy a workflow) into the same three-channel model.

The three-channel model

A single UniteLabs tenant hosts three parallel records of each workflow, distinguished by a display-name prefix the deploy script applies via --channel:

ChannelTriggerDisplay-name prefixIntent
DEVOperator-triggered (web UI or API)[DEV] Manual one-off deploys while iterating on a feature branch.
STGAuto on push to main[STG] Every workflow affected by the merge is redeployed to STG.
PRDAuto on per-workflow release tag <slug>/v<X.Y.Z>[PRD] One workflow ships at a verified version.

Bench testing on DEV can't risk the PRD record. STG always reflects what's on main. PRD is the source of truth for what's deployed in production.

Required secrets

Both platforms read the same four secrets:

VariableWhere to find it
BASE_URLhttps://api.<your-tenant>.unitelabs.io/
AUTH_URLhttps://auth.<your-tenant>.unitelabs.io/realms/<tenant>/protocol/openid-connect/
CLIENT_IDOAuth2 client ID
CLIENT_SECRETOAuth2 client secret (mark as masked)

GitHub: Settings → Secrets and variables → Actions → New repository secret.

GitLab: Settings → CI/CD → Variables. Mark CLIENT_SECRET as Masked so it never appears in logs; mark all four as Protected to restrict them to protected branches and tags.

GitHub Actions

The template ships three files under .github/workflows/:

deploy-dev.yml — manual

name: Deploy DEV

on:
  workflow_dispatch:
    inputs:
      workflow_slug:
        description: Workflow slug ([project].name from pyproject.toml)
        required: true
        type: string

permissions:
  contents: read

jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: astral-sh/setup-uv@v5
      - name: Deploy to DEV
        env:
          BASE_URL: ${{ secrets.BASE_URL }}
          AUTH_URL: ${{ secrets.AUTH_URL }}
          CLIENT_ID: ${{ secrets.CLIENT_ID }}
          CLIENT_SECRET: ${{ secrets.CLIENT_SECRET }}
        run: |
          SHORT_SHA="${GITHUB_SHA::7}"
          uv run scripts/deploy.py "${{ inputs.workflow_slug }}" \
            --channel dev --tag "$SHORT_SHA"
GitHub only renders the Run workflow button for workflow_dispatch jobs that exist on the repo's default branch. Merge the workflow file to main before expecting it to appear.

deploy-stg.yml — push to main, affected workflows only

name: Deploy STG

on:
  push:
    branches: [main]

permissions:
  contents: read

jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0 # full history so --changed-from can diff
      - uses: astral-sh/setup-uv@v5
      - name: Deploy affected workflows to STG
        env:
          BASE_URL: ${{ secrets.BASE_URL }}
          AUTH_URL: ${{ secrets.AUTH_URL }}
          CLIENT_ID: ${{ secrets.CLIENT_ID }}
          CLIENT_SECRET: ${{ secrets.CLIENT_SECRET }}
        run: |
          # First push to a new branch reports a null `before` ref; fall back
          # to HEAD~1 so we still compute a meaningful diff.
          BEFORE="${{ github.event.before }}"
          if [ "$BEFORE" = "0000000000000000000000000000000000000000" ]; then
            BEFORE="HEAD~1"
          fi
          SHORT_SHA="${GITHUB_SHA::7}"
          uv run scripts/deploy.py --changed-from "$BEFORE" \
            --channel stg --tag "$SHORT_SHA"

deploy-prd.yml — per-workflow release tag

name: Deploy PRD

on:
  push:
    tags:
      - '*/v*.*.*'

permissions:
  contents: read

jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: astral-sh/setup-uv@v5
      - name: Deploy tagged release to PRD
        env:
          BASE_URL: ${{ secrets.BASE_URL }}
          AUTH_URL: ${{ secrets.AUTH_URL }}
          CLIENT_ID: ${{ secrets.CLIENT_ID }}
          CLIENT_SECRET: ${{ secrets.CLIENT_SECRET }}
        run: |
          uv run scripts/deploy.py --git-tag "$GITHUB_REF_NAME" --channel prd

GitLab CI

The template's .gitlab-ci.yml defines a hidden .deploy-base template and three deploy jobs that extend it.

variables:
  SECRET_DETECTION_ENABLED: 'true'
  # WORKFLOW_SLUG renders as an input field on the "Run pipeline" web UI.
  # Pipeline-level (not job-level) so the `description:` form works.
  WORKFLOW_SLUG:
    value: ''
    description: 'Workflow slug for deploy-dev ([project].name from pyproject.toml)'

stages:
  - secret-detection
  - lint
  - deploy

# scripts/deploy.py needs only requests + python-dotenv + stdlib. We install
# them directly to avoid pulling unitelabs-* SDK git sources (no SSH creds on
# the runner). Image is `python:3.12` (NOT -slim) because deploy-stg's
# --changed-from shells out to `git diff`, which slim lacks.
.deploy-base:
  stage: deploy
  image: python:3.12
  before_script:
    - pip install --quiet requests python-dotenv

deploy-dev:
  extends: .deploy-base
  script:
    - test -n "$WORKFLOW_SLUG" || { echo "WORKFLOW_SLUG is required"; exit 1; }
    - python scripts/deploy.py "$WORKFLOW_SLUG" --channel dev --tag "$CI_COMMIT_SHORT_SHA"
  rules:
    # Operator-triggered pipelines only (web UI or `glab ci run`).
    - if: '$CI_PIPELINE_SOURCE == "web" || $CI_PIPELINE_SOURCE == "api"'
      when: manual

deploy-stg:
  extends: .deploy-base
  # Full history so `git diff` against $CI_COMMIT_BEFORE_SHA works for any
  # merge depth (default GitLab clone depth is 50).
  variables:
    GIT_DEPTH: '0'
  script:
    - |
      BEFORE="$CI_COMMIT_BEFORE_SHA"
      if [ "$BEFORE" = "0000000000000000000000000000000000000000" ]; then
        BEFORE="HEAD~1"
      fi
      python scripts/deploy.py --changed-from "$BEFORE" \
        --channel stg --tag "$CI_COMMIT_SHORT_SHA"
  rules:
    - if: '$CI_COMMIT_BRANCH == "main"'

deploy-prd:
  extends: .deploy-base
  script:
    - python scripts/deploy.py --git-tag "$CI_COMMIT_TAG" --channel prd
  rules:
    # Per-workflow release tags only: `<slug>/v<X.Y.Z>`.
    - if: '$CI_COMMIT_TAG =~ /^[a-z][a-z0-9-]*\/v\d+\.\d+\.\d+$/'
Three gotchas we hit when wiring this up:
  • description: doesn't work on job-level variables: — only at the top-level block. Put WORKFLOW_SLUG at the pipeline level (as shown) so the "Run pipeline" UI renders an input field.
  • python:3.12-slim has no git, and --changed-from shells out to git diff. Use the regular python:3.12 image.
  • The dev rule must include api, not just webglab ci run and other programmatic triggers report $CI_PIPELINE_SOURCE == "api".

The "everything affected" rule

deploy-stg deploys only workflows whose directory was touched in the merge — unless the diff includes shared/ or scripts/deploy.py. Those two are special:

  • shared/ is vendored into every workflow's bundle, so a change there reshapes every bundle.
  • scripts/deploy.py rewrites every bundle's contents.

Touching either marks every workflow as affected and STG redeploys them all. The rule lives in Python (affected_workflows() in scripts/deploy.py) so it's testable and shared between local and CI runs — not duplicated across two CI dialects.

End-to-end release walkthrough

Releasing w03-liquid-handling v1.2.0 through CI:

  1. Iterate on DEV while developing on a feature branch:
    # Local
    uv run scripts/deploy.py w03-liquid-handling --channel dev
    # Or via CI: trigger deploy-dev with WORKFLOW_SLUG=w03-liquid-handling
    
  2. Open an MR/PR and merge to maindeploy-stg fires automatically. If your diff touched shared/, all three workflows redeploy to STG; otherwise just the workflows you changed.
  3. Bump the workflow's version in a follow-up commit on main:
    $EDITOR w03-liquid-handling/pyproject.toml   # bump [project].version → 1.2.0
    git commit -am "release: w03-liquid-handling v1.2.0"
    git tag w03-liquid-handling/v1.2.0
    git push origin main --tags
    
  4. deploy-prd fires on the tag push. scripts/deploy.py --git-tag <slug>/v<X.Y.Z> parses the tag, verifies the pyproject version matches 1.2.0, and refuses if they disagree.

Verify a deployment

Each CI job prints a line per deployed workflow:

Affected workflows since 'origin/main': w03-liquid-handling
Authenticating...

--- [STG] Liquid Handling Demo (v1.2.0) ---
Building bundle...
Updating workflow id=70634682-5178-4881-b355-fbcb87448102
  resolved deps: 4 packages (101 chars)
Deployed: [STG] Liquid Handling Demo v1.2.0

Open the UniteLabs Workflows page and confirm the workflow appears with the expected [CHANNEL] prefix and version tag.

Next steps

  • Deploy a workflow — the full scripts/deploy.py reference, useful for local one-offs and for understanding what CI is actually running.
  • Trigger a workflow run — confirm a deployment end-to-end.