412 lines
17 KiB
YAML
412 lines
17 KiB
YAML
name: UI Preview
|
|
|
|
# Per-PR live preview of the Omnigent web UI, deployed to Databricks Apps.
|
|
# The preview is ephemeral (SQLite + local artifacts) and ships no LLM/runner --
|
|
# Omnigent runs agent turns on a runner the reviewer connects from their own
|
|
# machine. See .github/ui-preview/README.md.
|
|
|
|
on:
|
|
push:
|
|
branches:
|
|
- main
|
|
paths:
|
|
- web/**
|
|
- .github/workflows/ui-preview.yml
|
|
- .github/ui-preview/**
|
|
pull_request_target:
|
|
types:
|
|
- opened
|
|
- synchronize
|
|
- reopened
|
|
- labeled
|
|
- closed
|
|
|
|
concurrency:
|
|
group: ${{ github.workflow }}-${{ github.event.pull_request.number || 'main' }}
|
|
cancel-in-progress: true
|
|
|
|
defaults:
|
|
run:
|
|
shell: bash
|
|
|
|
env:
|
|
COMMENT_MARKER: "<!-- ui-preview -->"
|
|
|
|
permissions: {}
|
|
|
|
jobs:
|
|
notify:
|
|
if: >-
|
|
github.event_name != 'push'
|
|
&& github.event.action != 'closed'
|
|
&& contains(github.event.pull_request.labels.*.name, 'ui-preview')
|
|
&& github.event.pull_request.draft == false
|
|
&& contains(fromJSON('["OWNER", "MEMBER", "COLLABORATOR"]'), github.event.pull_request.author_association)
|
|
runs-on: ubuntu-latest
|
|
permissions:
|
|
pull-requests: write
|
|
timeout-minutes: 5
|
|
steps:
|
|
- env:
|
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
REPO: ${{ github.repository }}
|
|
PR_NUMBER: ${{ github.event.pull_request.number }}
|
|
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
|
|
run: |
|
|
BODY="${COMMENT_MARKER}
|
|
|
|
**UI Preview** is being deployed for this PR :hourglass_flowing_sand:
|
|
|
|
| | |
|
|
|---|---|
|
|
| **Commit** | ${HEAD_SHA} |
|
|
| **Run** | ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID} |
|
|
|
|
> Building and deploying... This comment will be updated with the preview URL."
|
|
|
|
# Only post if no existing comment (to avoid overwriting a previous preview URL)
|
|
COMMENT_ID=$(gh api --paginate \
|
|
"repos/${REPO}/issues/${PR_NUMBER}/comments" \
|
|
--jq ".[] | select(.body | startswith(\"$COMMENT_MARKER\")) | .id" \
|
|
| head -1)
|
|
|
|
if [ -z "$COMMENT_ID" ]; then
|
|
gh pr comment "$PR_NUMBER" --repo "$REPO" --body "$BODY"
|
|
fi
|
|
|
|
build:
|
|
if: >-
|
|
github.event_name == 'push'
|
|
|| (
|
|
github.event.action != 'closed'
|
|
&& contains(github.event.pull_request.labels.*.name, 'ui-preview')
|
|
&& github.event.pull_request.draft == false
|
|
&& contains(fromJSON('["OWNER", "MEMBER", "COLLABORATOR"]'), github.event.pull_request.author_association)
|
|
)
|
|
runs-on: ubuntu-latest
|
|
permissions:
|
|
contents: read
|
|
timeout-minutes: 30
|
|
steps:
|
|
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
|
with:
|
|
persist-credentials: false
|
|
# For PRs, check out the merge ref so the preview reflects what the UI
|
|
# will look like after merge. For push events, falls back to github.sha.
|
|
ref: ${{ github.event.pull_request.number && format('refs/pull/{0}/merge', github.event.pull_request.number) || github.sha }}
|
|
# checkout v7 blocks fork PR checkout on `pull_request_target` by
|
|
# default; opt in since this job builds the preview from fork code.
|
|
# Safe: it has no secrets (only `contents: read`), and the
|
|
# author_association guard above restricts it to OWNER/MEMBER/COLLABORATOR.
|
|
allow-unsafe-pr-checkout: true
|
|
|
|
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
|
with:
|
|
python-version-file: ".python-version"
|
|
- uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
|
|
with:
|
|
enable-cache: true
|
|
- uses: ./.github/actions/setup-node
|
|
|
|
- name: Build wheels (no UI)
|
|
# Build the wheels WITHOUT the SPA so they stay small (Databricks Apps
|
|
# caps each source wheel at 10MB). The SPA ships separately as
|
|
# build.tar.gz and is extracted at runtime by app.py. SKIP_WEB_UI skips
|
|
# build.sh's own npm build; OMNIGENT_SKIP_WEB_UI makes setup.py skip the
|
|
# in-wheel UI build.
|
|
env:
|
|
SKIP_WEB_UI: "1"
|
|
OMNIGENT_SKIP_WEB_UI: "true"
|
|
run: bash deploy/databricks/build.sh
|
|
|
|
- name: Build UI
|
|
working-directory: web
|
|
run: |
|
|
npm ci --legacy-peer-deps --no-audit --no-fund
|
|
npm run build
|
|
|
|
- name: Package UI assets
|
|
run: |
|
|
tar czf /tmp/build.tar.gz -C omnigent/server/static web-ui
|
|
UI_SIZE=$(stat -c %s /tmp/build.tar.gz)
|
|
echo "UI assets size: $(numfmt --to=iec "$UI_SIZE")"
|
|
|
|
- name: Prepare app files
|
|
run: |
|
|
mkdir -p /tmp/app-deploy
|
|
cp .github/ui-preview/app.py /tmp/app-deploy/
|
|
cp .github/ui-preview/app.yaml /tmp/app-deploy/
|
|
cp /tmp/build.tar.gz /tmp/app-deploy/
|
|
cp dist/*.whl /tmp/app-deploy/
|
|
for whl in /tmp/app-deploy/*.whl; do
|
|
size=$(stat -c %s "$whl")
|
|
echo "Wheel $(basename "$whl"): $(numfmt --to=iec "$size")"
|
|
# Fail fast: an oversize wheel can't be installed from the app source
|
|
# snapshot and would otherwise fail later in the deploy with a far
|
|
# less obvious error. (deploy/databricks/deploy.py raises here too.)
|
|
if [ "$size" -gt 10485760 ]; then
|
|
echo "::error::$(basename "$whl") exceeds the 10MB Databricks Apps wheel limit"
|
|
exit 1
|
|
fi
|
|
done
|
|
# Databricks Apps must install via uv (pyproject.toml + uv.lock), NOT a
|
|
# plain requirements.txt: the pip path uses the platform's Python 3.11,
|
|
# but omnigent requires >=3.12 -- uv provisions 3.12. The three wheels
|
|
# are wired as local path sources so they resolve from disk, not PyPI.
|
|
# Mirrors deploy/databricks/deploy.py (build_uv_pyproject + run_uv_lock).
|
|
python - <<'PY'
|
|
import glob, os
|
|
d = "/tmp/app-deploy"
|
|
def whl(prefix):
|
|
hits = [os.path.basename(p) for p in glob.glob(f"{d}/{prefix}*.whl")]
|
|
assert len(hits) == 1, (prefix, hits)
|
|
return hits[0]
|
|
sources = {
|
|
"omnigent": whl("omnigent-"),
|
|
"omnigent-client": whl("omnigent_client-"),
|
|
"omnigent-ui-sdk": whl("omnigent_ui_sdk-"),
|
|
}
|
|
lines = [
|
|
"[project]",
|
|
'name = "omnigent-ui-preview"',
|
|
'version = "0.0.0"',
|
|
'requires-python = ">=3.12,<3.13"',
|
|
"dependencies = [",
|
|
' "omnigent",',
|
|
' "omnigent-client",',
|
|
' "omnigent-ui-sdk",',
|
|
"]",
|
|
"",
|
|
"[tool.uv.sources]",
|
|
*[f'{name} = {{ path = "./{fname}" }}' for name, fname in sources.items()],
|
|
]
|
|
open(f"{d}/pyproject.toml", "w").write("\n".join(lines) + "\n")
|
|
print(open(f"{d}/pyproject.toml").read())
|
|
PY
|
|
( cd /tmp/app-deploy && uv lock --python 3.12 --index-url https://pypi.org/simple )
|
|
echo "app-deploy contents:"; ls -1 /tmp/app-deploy
|
|
|
|
- name: Upload app files
|
|
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
|
with:
|
|
name: app-deploy
|
|
path: /tmp/app-deploy/
|
|
retention-days: 1
|
|
if-no-files-found: error
|
|
|
|
deploy:
|
|
needs: build
|
|
# Use ubuntu-latest. If the Databricks workspace IP-allowlists, register a
|
|
# static-IP runner and switch `runs-on` to it.
|
|
runs-on: ubuntu-latest
|
|
permissions:
|
|
pull-requests: write
|
|
timeout-minutes: 30
|
|
steps:
|
|
- name: Download app files
|
|
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
|
with:
|
|
name: app-deploy
|
|
path: /tmp/app-deploy
|
|
|
|
- name: Install Databricks CLI
|
|
run: |
|
|
curl -fsSL https://raw.githubusercontent.com/databricks/setup-cli/2260866f83a41a2df55e1cfe7ffe038b78325bf6/install.sh | sh
|
|
databricks --version
|
|
|
|
- name: Create or update app
|
|
id: app
|
|
env:
|
|
DATABRICKS_HOST: ${{ secrets.DATABRICKS_HOST }}
|
|
DATABRICKS_CLIENT_ID: ${{ secrets.DATABRICKS_CLIENT_ID }}
|
|
DATABRICKS_CLIENT_SECRET: ${{ secrets.DATABRICKS_CLIENT_SECRET }}
|
|
DATABRICKS_AUTH_TYPE: oauth-m2m
|
|
APP_NAME: ${{ github.event_name == 'push' && 'omnigent-ui-preview-dev' || format('omnigent-ui-preview-pr-{0}', github.event.pull_request.number) }}
|
|
APP_DESCRIPTION: ${{ github.event.pull_request.html_url || format('{0}/{1}', github.server_url, github.repository) }}
|
|
run: |
|
|
if databricks apps get "$APP_NAME" > /dev/null 2>&1; then
|
|
echo "App already exists"
|
|
else
|
|
echo "Creating app..."
|
|
databricks apps create \
|
|
--json "{\"name\": \"$APP_NAME\", \"description\": \"$APP_DESCRIPTION\"}" \
|
|
--no-wait
|
|
for i in $(seq 1 40); do
|
|
STATE=$(databricks apps get "$APP_NAME" -o json | jq -r '.compute_status.state')
|
|
echo "Compute state: $STATE"
|
|
if [ "$STATE" = "ACTIVE" ]; then
|
|
break
|
|
elif [ "$STATE" = "ERROR" ] || [ "$STATE" = "STOPPED" ]; then
|
|
echo "::error::Compute entered $STATE state"
|
|
exit 1
|
|
fi
|
|
sleep 15
|
|
done
|
|
if [ "$STATE" != "ACTIVE" ]; then
|
|
echo "::error::Timed out waiting for compute to become ACTIVE (last state: $STATE)"
|
|
exit 1
|
|
fi
|
|
fi
|
|
|
|
URL=$(databricks apps get "$APP_NAME" -o json | jq -r '.url')
|
|
echo "url=$URL" >> "$GITHUB_OUTPUT"
|
|
|
|
- name: Upload files and deploy
|
|
env:
|
|
DATABRICKS_HOST: ${{ secrets.DATABRICKS_HOST }}
|
|
DATABRICKS_CLIENT_ID: ${{ secrets.DATABRICKS_CLIENT_ID }}
|
|
DATABRICKS_CLIENT_SECRET: ${{ secrets.DATABRICKS_CLIENT_SECRET }}
|
|
DATABRICKS_AUTH_TYPE: oauth-m2m
|
|
APP_NAME: ${{ github.event_name == 'push' && 'omnigent-ui-preview-dev' || format('omnigent-ui-preview-pr-{0}', github.event.pull_request.number) }}
|
|
WORKSPACE_PATH: /Users/${{ secrets.DATABRICKS_CLIENT_ID }}/apps/${{ github.event_name == 'push' && 'omnigent-ui-preview-dev' || format('omnigent-ui-preview-pr-{0}', github.event.pull_request.number) }}
|
|
run: |
|
|
# Wipe the workspace source dir first. import-dir --overwrite only
|
|
# replaces files it uploads; it does NOT prune orphans. A requirements.txt
|
|
# left by an earlier deploy would otherwise survive and take precedence
|
|
# over uv (pyproject.toml + uv.lock), forcing the pip/Python-3.11 install
|
|
# path that fails omnigent's requires-python >=3.12.
|
|
databricks workspace delete "$WORKSPACE_PATH" --recursive 2>/dev/null || true
|
|
databricks workspace mkdirs "$WORKSPACE_PATH" 2>/dev/null || true
|
|
databricks workspace import-dir /tmp/app-deploy "$WORKSPACE_PATH" --overwrite
|
|
databricks apps deploy "$APP_NAME" --source-code-path "/Workspace$WORKSPACE_PATH"
|
|
|
|
- name: Restart app to load the new code
|
|
env:
|
|
DATABRICKS_HOST: ${{ secrets.DATABRICKS_HOST }}
|
|
DATABRICKS_CLIENT_ID: ${{ secrets.DATABRICKS_CLIENT_ID }}
|
|
DATABRICKS_CLIENT_SECRET: ${{ secrets.DATABRICKS_CLIENT_SECRET }}
|
|
DATABRICKS_AUTH_TYPE: oauth-m2m
|
|
APP_NAME: ${{ github.event_name == 'push' && 'omnigent-ui-preview-dev' || format('omnigent-ui-preview-pr-{0}', github.event.pull_request.number) }}
|
|
run: |
|
|
# `apps deploy` restarts the app process and re-extracts source, but
|
|
# reuses the existing Python env, so a freshly built wheel is not
|
|
# reinstalled. Stop then start so the env is rebuilt from the deployed
|
|
# source.
|
|
echo "Stopping app..."
|
|
databricks apps stop "$APP_NAME"
|
|
for i in $(seq 1 40); do
|
|
STATE=$(databricks apps get "$APP_NAME" -o json | jq -r '.compute_status.state')
|
|
echo "Compute state: $STATE"
|
|
if [ "$STATE" = "STOPPED" ]; then
|
|
break
|
|
elif [ "$STATE" = "ERROR" ]; then
|
|
echo "::error::Compute entered ERROR state while stopping"
|
|
exit 1
|
|
fi
|
|
sleep 15
|
|
done
|
|
|
|
echo "Starting app..."
|
|
databricks apps start "$APP_NAME"
|
|
for i in $(seq 1 40); do
|
|
STATE=$(databricks apps get "$APP_NAME" -o json | jq -r '.compute_status.state')
|
|
echo "Compute state: $STATE"
|
|
if [ "$STATE" = "ACTIVE" ]; then
|
|
break
|
|
elif [ "$STATE" = "ERROR" ]; then
|
|
echo "::error::Compute entered ERROR state"
|
|
exit 1
|
|
fi
|
|
sleep 15
|
|
done
|
|
if [ "$STATE" != "ACTIVE" ]; then
|
|
echo "::error::Timed out waiting for compute to become ACTIVE (last state: $STATE)"
|
|
exit 1
|
|
fi
|
|
|
|
- name: Print app URL
|
|
if: github.event_name == 'push'
|
|
env:
|
|
APP_URL: ${{ steps.app.outputs.url }}
|
|
run: echo "Deployed to $APP_URL" >> "$GITHUB_STEP_SUMMARY"
|
|
|
|
- name: Comment on PR
|
|
if: github.event_name != 'push'
|
|
env:
|
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
APP_URL: ${{ steps.app.outputs.url }}
|
|
COMMIT_SHA: ${{ github.event.pull_request.head.sha }}
|
|
REPO: ${{ github.repository }}
|
|
PR_NUMBER: ${{ github.event.pull_request.number }}
|
|
run: |
|
|
BODY="${COMMENT_MARKER}
|
|
|
|
**UI Preview** is ready for this PR :rocket:
|
|
|
|
| | |
|
|
|---|---|
|
|
| **URL** | ${APP_URL} |
|
|
| **Commit** | $COMMIT_SHA |
|
|
| **Run** | ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID} |
|
|
|
|
> [!NOTE]
|
|
> This preview is only accessible to maintainers with workspace access.
|
|
> It serves the UI only -- connect your own host (\`omnigent run … --server <url>\`) to drive a real session.
|
|
> The preview updates automatically when new commits are pushed."
|
|
|
|
COMMENT_ID=$(gh api --paginate \
|
|
"repos/$REPO/issues/$PR_NUMBER/comments" \
|
|
--jq ".[] | select(.body | startswith(\"$COMMENT_MARKER\")) | .id" \
|
|
| head -1)
|
|
|
|
if [ -n "$COMMENT_ID" ]; then
|
|
gh api "repos/$REPO/issues/comments/$COMMENT_ID" -X PATCH -f body="$BODY"
|
|
else
|
|
gh pr comment "$PR_NUMBER" --repo "$REPO" --body "$BODY"
|
|
fi
|
|
|
|
cleanup:
|
|
# No `ui-preview` label gate here on purpose: if the label is removed before
|
|
# the PR closes, a labelled-then-unlabelled PR would otherwise leak its app
|
|
# and workspace files forever. Run on every close; the delete step is a cheap
|
|
# no-op (one existence check) for PRs that never had a preview.
|
|
if: >-
|
|
github.event_name != 'push'
|
|
&& github.event.action == 'closed'
|
|
runs-on: ubuntu-latest
|
|
permissions:
|
|
pull-requests: write
|
|
timeout-minutes: 10
|
|
steps:
|
|
- name: Install Databricks CLI
|
|
run: |
|
|
curl -fsSL https://raw.githubusercontent.com/databricks/setup-cli/2260866f83a41a2df55e1cfe7ffe038b78325bf6/install.sh | sh
|
|
databricks --version
|
|
|
|
- name: Delete app
|
|
env:
|
|
DATABRICKS_HOST: ${{ secrets.DATABRICKS_HOST }}
|
|
DATABRICKS_CLIENT_ID: ${{ secrets.DATABRICKS_CLIENT_ID }}
|
|
DATABRICKS_CLIENT_SECRET: ${{ secrets.DATABRICKS_CLIENT_SECRET }}
|
|
DATABRICKS_AUTH_TYPE: oauth-m2m
|
|
APP_NAME: omnigent-ui-preview-pr-${{ github.event.pull_request.number }}
|
|
run: |
|
|
if databricks apps get "$APP_NAME" > /dev/null 2>&1; then
|
|
SOURCE_PATH=$(databricks apps get "$APP_NAME" -o json \
|
|
| jq -r '.default_source_code_path // empty')
|
|
databricks apps delete "$APP_NAME" --auto-approve
|
|
if [ -n "$SOURCE_PATH" ]; then
|
|
WS_PATH="${SOURCE_PATH#/Workspace}"
|
|
databricks workspace delete "$WS_PATH" --recursive 2>/dev/null || true
|
|
fi
|
|
fi
|
|
|
|
- name: Update PR comment
|
|
env:
|
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
REPO: ${{ github.repository }}
|
|
PR_NUMBER: ${{ github.event.pull_request.number }}
|
|
run: |
|
|
BODY="${COMMENT_MARKER}
|
|
|
|
**UI Preview** for this PR has been removed."
|
|
|
|
COMMENT_ID=$(gh api --paginate \
|
|
"repos/$REPO/issues/$PR_NUMBER/comments" \
|
|
--jq ".[] | select(.body | startswith(\"$COMMENT_MARKER\")) | .id" \
|
|
| head -1)
|
|
|
|
if [ -n "$COMMENT_ID" ]; then
|
|
gh api "repos/$REPO/issues/comments/$COMMENT_ID" -X PATCH -f body="$BODY"
|
|
fi
|