CI/CD for workflows
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:
| Channel | Trigger | Display-name prefix | Intent |
|---|---|---|---|
| DEV | Operator-triggered (web UI or API) | [DEV] | Manual one-off deploys while iterating on a feature branch. |
| STG | Auto on push to main | [STG] | Every workflow affected by the merge is redeployed to STG. |
| PRD | Auto 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:
| Variable | Where to find it |
|---|---|
BASE_URL | https://api.<your-tenant>.unitelabs.io/ |
AUTH_URL | https://auth.<your-tenant>.unitelabs.io/realms/<tenant>/protocol/openid-connect/ |
CLIENT_ID | OAuth2 client ID |
CLIENT_SECRET | OAuth2 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"
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+$/'
description:doesn't work on job-levelvariables:— only at the top-level block. PutWORKFLOW_SLUGat the pipeline level (as shown) so the "Run pipeline" UI renders an input field.python:3.12-slimhas nogit, and--changed-fromshells out togit diff. Use the regularpython:3.12image.- The dev rule must include
api, not justweb—glab ci runand 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.pyrewrites 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:
- 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 - Open an MR/PR and merge to
main—deploy-stgfires automatically. If your diff touchedshared/, all three workflows redeploy to STG; otherwise just the workflows you changed. - 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 deploy-prdfires on the tag push.scripts/deploy.py --git-tag <slug>/v<X.Y.Z>parses the tag, verifies the pyproject version matches1.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.pyreference, useful for local one-offs and for understanding what CI is actually running. - Trigger a workflow run — confirm a deployment end-to-end.