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: "" 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 \`) 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