CI/CD for workflows
The workflow template ships ready-to-use CI/CD for both GitHub Actions and GitLab CI: pipelines your Git host runs automatically to check and deploy workflows on every push or release. Both wire scripts/deploy.py (see Deploy a workflow) into the same three-channel model.
The three-channel model
A workflow record is one entry on the platform's Workflows page: the thing an operator picks to start a run. It holds the uploaded bundle, the entrypoint, the resolved dependency list, and the tags.
The platform itself has no channel or environment concept. It identifies a record by its display name, and that is what --channel works with: it prepends [DEV] , [STG] , or [PRD] to the name before deploying. Three channels therefore produce three differently named records of the same workflow in one tenant:
| 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. |
So w02-liquid-handling, whose display name is Liquid Handling Demo (from [tool.unitelabs.workflow].display_name in its pyproject.toml), ends up as three entries: [DEV] Liquid Handling Demo, [STG] Liquid Handling Demo, and [PRD] Liquid Handling Demo. Each carries its own bundle, version, and tags.
A deploy only updates the record whose name matches, so a DEV deploy cannot touch PRD. That is the whole isolation mechanism: you can run against real instruments on DEV while PRD keeps serving production. STG always reflects main, and PRD shows what production runs.
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 copied 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 w02-liquid-handling v1.2.0 through CI:
- Iterate on DEV while developing on a feature branch:
# Local uv run scripts/deploy.py w02-liquid-handling --channel dev # Or via CI: trigger deploy-dev with WORKFLOW_SLUG=w02-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 w02-liquid-handling/pyproject.toml # bump [project].version → 1.2.0 git commit -am "release: w02-liquid-handling v1.2.0" git tag w02-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': w02-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.