Skip to content

Commit ed316e0

Browse files
authored
Add working CI (#82)
* Fix Databricks pypi proxy hostname The hostname is pypi-proxy.cloud.databricks.com (hyphen), not pypi.proxy.cloud.databricks.com — the dotted form doesn't resolve. * Add network egress diagnostics to CI test job Confirms whether the runner can reach files.pythonhosted.org, pypi-proxy.cloud.databricks.com, and pypi.org. Earlier runs suggested the runner can reach pythonhosted directly (wheel downloads succeed) but can't reach the proxy or pypi.org for /simple/ lookups. Run this to get hard evidence before talking to the runner-group owners. Errors are tolerated so we see all three results before pytest runs. * Switch CI to standard ubuntu-latest runners The databricks-protected-runner-group's egress firewall blocks TLS to pypi.org and pypi-proxy.cloud.databricks.com (only files.pythonhosted.org is reachable for TLS handshakes), which prevents uv from resolving build-isolation deps like setuptools to compile thrift's sdist. Other Databricks Python repos (e.g. databricks-ai-bridge) run their pytest CI on plain ubuntu-latest with no proxy and it works fine. Switch both jobs to match. The test job has no need for protected egress, and e2e only uses secrets passed via env, which work the same on either runner. Also removes the now-unused UV_INDEX_URL env var and the temporary network diagnostic step. * Strip ANSI before substring-asserting on typer help output CI runners (e.g. GitHub Actions ubuntu-latest) set FORCE_COLOR=1, which makes rich/typer render --help with SGR escapes that split styled tokens across ANSI codes. ``"--agents" in result.output`` then fails because the rendered output is actually ``--\x1b[0m\x1b[1magents``. The test passed locally because non-TTY runs don't get colored. Strip ANSI before checking so the assertion holds either way. * Fix pre-existing ruff format and ty failures - ruff format: auto-formatted src/ucode/databricks.py (collapsed lines ruff now considers fitting under the 100-char limit). - ty: typed the run() wrapper with @overload so text=True narrows to CompletedProcess[str] (every caller that reads the return value uses text=True). Also guard _scrub_json's dict-key match against non-str keys so re.Pattern[str].search gets a str. Test suite stays at 535 passing. * Drop accidentally-committed local-only files The prior commit ran `git add -A` and swept up local-only scratch files (.antigravitycli/, .claude/, .vscode/, OPENCODE_PLAN.md, mlflow.db, scripts/) that should never have been tracked. Removing them from the index — they remain on disk locally. * Close stdin on the e2e pytest run The Databricks CLI's `auth login --no-browser` fallback path reads stdin for the user to paste an OAuth code. In CI the runner's stdin sometimes keeps the subprocess alive past Python's timeout, hanging the whole job for the full ~6h workflow limit. Redirecting stdin to /dev/null makes that fallback EOF immediately, so if M2M env vars don't yield a token we fail fast with a clear error instead of hanging. * Add M2M auth diagnostic step before e2e pytest Last CI run failed with `Databricks CLI returned no access token` after get_databricks_token's pytest fixture couldn't authenticate via M2M env vars. The CLI's stderr is captured (and discarded) by ucode so we never see *why* M2M failed. This diagnostic step prints the CLI version, which auth env vars are set, whether ~/.databrickscfg exists, and the raw output of `databricks auth token` + `current-user me` so the next run will tell us exactly what's wrong. * Use DATABRICKS_BEARER as CI escape hatch for e2e auth `databricks auth token` only returns cached user-OAuth tokens (from `databricks auth login`) — it has no M2M path. Hosted-runner CI has no ~/.databrickscfg and no cached login, so the CLI command always errors with "OAuth is not configured for this host", and ucode's token helper hangs on the `auth login --no-browser` fallback waiting for stdin. Teach has_valid_databricks_auth and get_databricks_token to short-circuit to a pre-fetched DATABRICKS_BEARER env var (the same env var build_auth_shell_command already honors). The e2e workflow pulls it from a repo secret; user fetches a fresh bearer (e.g. M2M client_credentials against /oidc/v1/token) and pastes it into Settings → Secrets when the token expires (~1h). Removes DATABRICKS_CLIENT_ID/_SECRET from the workflow — they're no longer load-bearing now that we don't call the CLI for auth. * Fix e2e test monkeypatches + bump web-search timeout - tests/test_e2e.py: TestConfigureSubset patches `run_databricks_login` at the wrong module path. cli.py does `from ucode.databricks import run_databricks_login`, so patching `ucode.databricks.run_databricks_login` doesn't affect the local name cli uses, and the real function runs (which opens a browser). Patch `ucode.cli.run_databricks_login` instead. - mcp_web_search.py: bump urllib timeout from 60s to 180s. Codex Responses API with native web_search does a real web fetch + LLM completion, which legitimately can exceed 60s under load. * Install agent CLIs in CI so launch tests don't skip The six TestXxxLaunch tests all call `_require_binary("...")` and skip when the agent isn't on PATH. The runner has only Node.js, not the agents themselves, so every launch test skipped. Install all six via `npm install -g` so they exercise real binaries against the e2e workspace.
1 parent d5f2671 commit ed316e0

5 files changed

Lines changed: 94 additions & 27 deletions

File tree

.github/workflows/ci.yml

Lines changed: 27 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -11,31 +11,46 @@ permissions:
1111

1212
jobs:
1313
test:
14-
runs-on:
15-
group: databricks-protected-runner-group
16-
labels: linux-ubuntu-latest
17-
env:
18-
UV_INDEX_URL: https://pypi.proxy.cloud.databricks.com/simple
14+
runs-on: ubuntu-latest
1915
steps:
2016
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
2117
- uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
2218
- run: uv run pytest --ignore=tests/test_e2e.py
2319

2420
e2e:
2521
if: vars.E2E_ENABLED == 'true'
26-
runs-on:
27-
group: databricks-protected-runner-group
28-
labels: linux-ubuntu-latest
22+
runs-on: ubuntu-latest
2923
env:
30-
UV_INDEX_URL: https://pypi.proxy.cloud.databricks.com/simple
3124
UCODE_TEST_WORKSPACE: ${{ secrets.UCODE_TEST_WORKSPACE }}
3225
DATABRICKS_HOST: ${{ secrets.UCODE_TEST_WORKSPACE }}
33-
DATABRICKS_CLIENT_ID: ${{ secrets.DATABRICKS_CLIENT_ID }}
34-
DATABRICKS_CLIENT_SECRET: ${{ secrets.DATABRICKS_CLIENT_SECRET }}
26+
# DATABRICKS_BEARER is the CI escape hatch: `databricks auth token`
27+
# only retrieves cached user-OAuth tokens, so on a hosted runner
28+
# (no databrickscfg, no cached login) it can never produce a bearer.
29+
# Pre-fetch one (e.g. via M2M OAuth client_credentials against
30+
# /oidc/v1/token) and store it as a repo secret. Both
31+
# has_valid_databricks_auth + get_databricks_token + the agents'
32+
# apiKeyHelper short-circuit to this value when set. Tokens are
33+
# short-lived (~1h); rotate when CI starts failing with 401s.
34+
DATABRICKS_BEARER: ${{ secrets.DATABRICKS_BEARER }}
3535
steps:
3636
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
3737
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
3838
- uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
3939
- uses: databricks/setup-cli@bdb89f81c11a5bd647fd55b585b7c396ec68a25a # v1.0.0
40+
# The agent launch tests `_require_binary("codex")` etc. and skip when
41+
# the CLI isn't on PATH. Install all six so each TestXxxLaunch test
42+
# actually runs instead of skipping.
43+
- name: Install agent CLIs
44+
run: npm install -g
45+
@anthropic-ai/claude-code
46+
@openai/codex
47+
@google/gemini-cli
48+
opencode-ai
49+
@github/copilot
50+
@earendil-works/pi-coding-agent
4051
- run: uv tool install .
41-
- run: uv run pytest tests/test_e2e.py -v
52+
# Redirect stdin so any interactive `databricks auth login --no-browser`
53+
# fallback EOFs instead of hanging the runner. With DATABRICKS_BEARER
54+
# set, the auth code path doesn't shell out at all — this is a safety
55+
# net for any code path we may have missed.
56+
- run: uv run pytest tests/test_e2e.py -v < /dev/null

src/ucode/databricks.py

Lines changed: 48 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
import shutil
1515
import subprocess
1616
from pathlib import Path
17-
from typing import cast
17+
from typing import Literal, cast, overload
1818
from urllib import error as urllib_error
1919
from urllib import request as urllib_request
2020
from urllib.parse import urlparse
@@ -90,9 +90,7 @@ def _debug(label: str, detail: str) -> None:
9090
logger.debug("%s: %s", label, detail)
9191

9292

93-
_SECRET_KEY_PATTERN = re.compile(
94-
r"(token|secret|password|bearer|api_key|apikey)", re.IGNORECASE
95-
)
93+
_SECRET_KEY_PATTERN = re.compile(r"(token|secret|password|bearer|api_key|apikey)", re.IGNORECASE)
9694

9795

9896
def _format_subprocess_result(
@@ -127,7 +125,11 @@ def _scrub_databrickscfg(text: str) -> str:
127125
def _scrub_json(value: object) -> object:
128126
if isinstance(value, dict):
129127
return {
130-
k: ("<redacted>" if _SECRET_KEY_PATTERN.search(k) else _scrub_json(v))
128+
k: (
129+
"<redacted>"
130+
if isinstance(k, str) and _SECRET_KEY_PATTERN.search(k)
131+
else _scrub_json(v)
132+
)
131133
for k, v in value.items()
132134
}
133135
if isinstance(value, list):
@@ -225,6 +227,30 @@ def _http_get_json(
225227
return None, f"network error: {exc.reason}"
226228

227229

230+
@overload
231+
def run(
232+
args: list[str],
233+
*,
234+
check: bool = True,
235+
capture_output: bool = False,
236+
text: Literal[True],
237+
env: dict[str, str] | None = None,
238+
timeout: int | None = None,
239+
) -> subprocess.CompletedProcess[str]: ...
240+
241+
242+
@overload
243+
def run(
244+
args: list[str],
245+
*,
246+
check: bool = True,
247+
capture_output: bool = False,
248+
text: Literal[False] = False,
249+
env: dict[str, str] | None = None,
250+
timeout: int | None = None,
251+
) -> subprocess.CompletedProcess[bytes]: ...
252+
253+
228254
def run(
229255
args: list[str],
230256
*,
@@ -331,6 +357,11 @@ def install_databricks_cli() -> None:
331357

332358

333359
def has_valid_databricks_auth(workspace: str) -> bool:
360+
# Honor the CI short-circuit (see ``get_databricks_token``): if a
361+
# pre-fetched bearer is available, treat auth as valid and skip the
362+
# `databricks auth token` shell-out (which only knows user-OAuth).
363+
if os.environ.get("DATABRICKS_BEARER", "").strip():
364+
return True
334365
_log_auth_diagnostics()
335366
try:
336367
env = build_databricks_cli_env(workspace)
@@ -419,6 +450,17 @@ def ensure_databricks_auth(workspace: str) -> None:
419450

420451

421452
def get_databricks_token(workspace: str, *, force_refresh: bool = False) -> str:
453+
# ``DATABRICKS_BEARER`` is the CI escape hatch: when set, skip the
454+
# `databricks auth token` subprocess entirely and return the pre-fetched
455+
# bearer directly. Used by the e2e job, where the protected runner has
456+
# no `databricks auth login` cache and `databricks auth token` only knows
457+
# how to read user-OAuth caches (not M2M client_credentials). Mirrors the
458+
# same short-circuit baked into ``build_auth_shell_command``.
459+
bearer = os.environ.get("DATABRICKS_BEARER", "").strip()
460+
if bearer:
461+
_debug("get_databricks_token", "using DATABRICKS_BEARER env var")
462+
return bearer
463+
422464
_log_auth_diagnostics()
423465
env = build_databricks_cli_env(workspace)
424466
cmd = ["databricks", "auth", "token", "--host", workspace, "--output", "json"]
@@ -429,9 +471,7 @@ def get_databricks_token(workspace: str, *, force_refresh: bool = False) -> str:
429471
"get_databricks_token.env",
430472
"set="
431473
+ ",".join(
432-
sorted(
433-
k for k in env if k.startswith("DATABRICKS_") or k in {"BUNDLE_PROFILE"}
434-
)
474+
sorted(k for k in env if k.startswith("DATABRICKS_") or k in {"BUNDLE_PROFILE"})
435475
),
436476
)
437477

src/ucode/mcp_web_search.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -119,7 +119,7 @@ def _call_responses_api(query: str) -> dict[str, Any]:
119119
},
120120
)
121121
try:
122-
with urllib_request.urlopen(request, timeout=60) as response:
122+
with urllib_request.urlopen(request, timeout=180) as response:
123123
raw = response.read().decode("utf-8")
124124
except urllib_error.HTTPError as exc:
125125
detail = ""

tests/test_cli.py

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,24 @@
22

33
from __future__ import annotations
44

5+
import re
56
from unittest.mock import patch
67

78
import pytest
89
from typer.testing import CliRunner
910

1011
from ucode.cli import app
1112

13+
_ANSI_RE = re.compile(r"\x1b\[[0-9;]*m")
14+
15+
16+
def _strip_ansi(text: str) -> str:
17+
"""Drop SGR escape sequences so substring assertions match regardless of
18+
whether the runner forces color rendering (e.g. CI sets FORCE_COLOR=1,
19+
which makes rich split styled tokens like ``--agents`` with ANSI codes)."""
20+
return _ANSI_RE.sub("", text)
21+
22+
1223
runner = CliRunner()
1324

1425
TOOLS = ["codex", "claude", "gemini", "opencode"]
@@ -68,9 +79,10 @@ def test_subcommand_help(self, tool):
6879
def test_configure_help_lists_agents_flag(self):
6980
result = runner.invoke(app, ["configure", "--help"])
7081
assert result.exit_code == 0
71-
assert "--agents" in result.output
72-
assert "comma-separated list of agents" in result.output
73-
assert "--workspaces" in result.output
82+
output = _strip_ansi(result.output)
83+
assert "--agents" in output
84+
assert "comma-separated list of agents" in output
85+
assert "--workspaces" in output
7486

7587

7688
def _patch_launch(tool: str):

tests/test_e2e.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -213,7 +213,7 @@ def test_only_picks_codex_writes_only_codex_config(self, tmp_path, monkeypatch,
213213
monkeypatch.setattr(state_mod, "STATE_PATH", tmp_path / "state.json")
214214
# Don't actually run `databricks auth login`; the developer running
215215
# this suite is already authenticated.
216-
monkeypatch.setattr("ucode.databricks.run_databricks_login", lambda ws: None)
216+
monkeypatch.setattr("ucode.cli.run_databricks_login", lambda ws: None)
217217
# Skip the workspace prompt and the multi-select picker.
218218
monkeypatch.setattr(cli_mod, "_prompt_for_configuration", lambda tool=None: e2e_workspace)
219219
monkeypatch.setattr(cli_mod, "prompt_for_tools", lambda available: ["codex"])
@@ -248,7 +248,7 @@ def test_rerun_with_different_pick_preserves_previous(
248248

249249
self._redirect_config_paths(monkeypatch, tmp_path)
250250
monkeypatch.setattr(state_mod, "STATE_PATH", tmp_path / "state.json")
251-
monkeypatch.setattr("ucode.databricks.run_databricks_login", lambda ws: None)
251+
monkeypatch.setattr("ucode.cli.run_databricks_login", lambda ws: None)
252252
monkeypatch.setattr(cli_mod, "_prompt_for_configuration", lambda tool=None: e2e_workspace)
253253
monkeypatch.setattr(
254254
cli_mod, "install_tool_binary", lambda tool, strict=False, update_existing=False: True
@@ -281,7 +281,7 @@ def test_empty_pick_returns_zero_and_writes_nothing(self, tmp_path, monkeypatch,
281281

282282
codex_path = self._redirect_config_paths(monkeypatch, tmp_path)
283283
monkeypatch.setattr(state_mod, "STATE_PATH", tmp_path / "state.json")
284-
monkeypatch.setattr("ucode.databricks.run_databricks_login", lambda ws: None)
284+
monkeypatch.setattr("ucode.cli.run_databricks_login", lambda ws: None)
285285
monkeypatch.setattr(cli_mod, "_prompt_for_configuration", lambda tool=None: e2e_workspace)
286286
monkeypatch.setattr(cli_mod, "prompt_for_tools", lambda available: [])
287287
install_calls: list[str] = []

0 commit comments

Comments
 (0)