Skip to content

Commit aa954ce

Browse files
Allow override git ref/sha in docker build by configuring BuildConfig (#1100)
Co-authored-by: openhands <openhands@all-hands.dev>
1 parent f8d847d commit aa954ce

File tree

2 files changed

+162
-139
lines changed

2 files changed

+162
-139
lines changed

openhands-agent-server/openhands/agent_server/docker/build.py

Lines changed: 152 additions & 129 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,105 @@
4343
# --- helpers ---
4444

4545

46+
def _default_sdk_project_root() -> Path:
47+
"""
48+
Resolve top-level OpenHands UV workspace root:
49+
50+
Order:
51+
1) Walk up from CWD
52+
2) Walk up from this file location
53+
54+
Reject anything in site/dist-packages (installed wheels).
55+
"""
56+
site_markers = ("site-packages", "dist-packages")
57+
58+
def _is_workspace_root(d: Path) -> bool:
59+
"""Detect if d is the root of the Agent-SDK repo UV workspace."""
60+
_EXPECTED = (
61+
"openhands-sdk/pyproject.toml",
62+
"openhands-tools/pyproject.toml",
63+
"openhands-workspace/pyproject.toml",
64+
"openhands-agent-server/pyproject.toml",
65+
)
66+
67+
py = d / "pyproject.toml"
68+
if not py.exists():
69+
return False
70+
try:
71+
cfg = tomllib.loads(py.read_text(encoding="utf-8"))
72+
except Exception:
73+
cfg = {}
74+
members = (
75+
cfg.get("tool", {}).get("uv", {}).get("workspace", {}).get("members", [])
76+
or []
77+
)
78+
# Accept either explicit UV members or structural presence of all subprojects
79+
if members:
80+
norm = {str(Path(m)) for m in members}
81+
return {
82+
"openhands-sdk",
83+
"openhands-tools",
84+
"openhands-workspace",
85+
"openhands-agent-server",
86+
}.issubset(norm)
87+
return all((d / p).exists() for p in _EXPECTED)
88+
89+
def _climb(start: Path) -> Path | None:
90+
cur = start.resolve()
91+
if not cur.is_dir():
92+
cur = cur.parent
93+
while True:
94+
if _is_workspace_root(cur):
95+
return cur
96+
if cur.parent == cur:
97+
return None
98+
cur = cur.parent
99+
100+
def validate(p: Path, src: str) -> Path:
101+
if any(s in str(p) for s in site_markers):
102+
raise RuntimeError(
103+
f"{src}: points inside site-packages; need the source checkout."
104+
)
105+
root = _climb(p) or p
106+
if not _is_workspace_root(root):
107+
raise RuntimeError(
108+
f"{src}: couldn't find the OpenHands UV workspace root "
109+
f"starting at '{p}'.\n\n"
110+
"Expected setup (repo root):\n"
111+
" pyproject.toml # has [tool.uv.workspace] with members\n"
112+
" openhands-sdk/pyproject.toml\n"
113+
" openhands-tools/pyproject.toml\n"
114+
" openhands-workspace/pyproject.toml\n"
115+
" openhands-agent-server/pyproject.toml\n\n"
116+
"Fix:\n"
117+
" - Run from anywhere inside the repo."
118+
)
119+
return root
120+
121+
if root := _climb(Path.cwd()):
122+
return validate(root, "CWD discovery")
123+
124+
try:
125+
here = Path(__file__).resolve()
126+
if root := _climb(here):
127+
return validate(root, "__file__ discovery")
128+
except NameError:
129+
pass
130+
131+
# Final, user-facing guidance
132+
raise RuntimeError(
133+
"Could not resolve the OpenHands UV workspace root.\n\n"
134+
"Expected repo layout:\n"
135+
" pyproject.toml (with [tool.uv.workspace].members "
136+
"including openhands/* subprojects)\n"
137+
" openhands-sdk/pyproject.toml\n"
138+
" openhands-tools/pyproject.toml\n"
139+
" openhands-workspace/pyproject.toml\n"
140+
" openhands-agent-server/pyproject.toml\n\n"
141+
"Run this from inside the repo."
142+
)
143+
144+
46145
def _run(
47146
cmd: list[str],
48147
cwd: str | None = None,
@@ -149,9 +248,9 @@ def _base_slug(image: str, max_len: int = 64) -> str:
149248
return kept + suffix
150249

151250

152-
def _git_info() -> tuple[str, str, str]:
251+
def _git_info() -> tuple[str, str]:
153252
"""
154-
Get git info (ref, sha, short_sha) for the current working directory.
253+
Get git info (ref, sha) for the current working directory.
155254
156255
Priority order for SHA:
157256
1. SDK_SHA - Explicit override (e.g., for submodule builds)
@@ -163,23 +262,27 @@ def _git_info() -> tuple[str, str, str]:
163262
2. GITHUB_REF - GitHub Actions environment
164263
3. git symbolic-ref HEAD - Local development
165264
"""
265+
sdk_root = _default_sdk_project_root()
166266
git_sha = os.environ.get("SDK_SHA") or os.environ.get("GITHUB_SHA")
167267
if not git_sha:
168268
try:
169-
git_sha = _run(["git", "rev-parse", "--verify", "HEAD"]).stdout.strip()
269+
git_sha = _run(
270+
["git", "rev-parse", "--verify", "HEAD"],
271+
cwd=str(sdk_root),
272+
).stdout.strip()
170273
except subprocess.CalledProcessError:
171274
git_sha = "unknown"
172-
short_sha = git_sha[:7] if git_sha != "unknown" else "unknown"
173275

174276
git_ref = os.environ.get("SDK_REF") or os.environ.get("GITHUB_REF")
175277
if not git_ref:
176278
try:
177279
git_ref = _run(
178-
["git", "symbolic-ref", "-q", "--short", "HEAD"]
280+
["git", "symbolic-ref", "-q", "--short", "HEAD"],
281+
cwd=str(sdk_root),
179282
).stdout.strip()
180283
except subprocess.CalledProcessError:
181284
git_ref = "unknown"
182-
return git_ref, git_sha, short_sha
285+
return git_ref, git_sha
183286

184287

185288
def _package_version() -> str:
@@ -204,119 +307,12 @@ def _package_version() -> str:
204307
return "unknown"
205308

206309

207-
GIT_REF, GIT_SHA, SHORT_SHA = _git_info()
208-
PACKAGE_VERSION = _package_version()
209-
210-
211-
# --- options ---
212-
213-
214-
def _is_workspace_root(d: Path) -> bool:
215-
"""Detect if d is the root of the Agent-SDK repo UV workspace."""
216-
_EXPECTED = (
217-
"openhands-sdk/pyproject.toml",
218-
"openhands-tools/pyproject.toml",
219-
"openhands-workspace/pyproject.toml",
220-
"openhands-agent-server/pyproject.toml",
221-
)
222-
223-
py = d / "pyproject.toml"
224-
if not py.exists():
225-
return False
226-
try:
227-
cfg = tomllib.loads(py.read_text(encoding="utf-8"))
228-
except Exception:
229-
cfg = {}
230-
members = (
231-
cfg.get("tool", {}).get("uv", {}).get("workspace", {}).get("members", []) or []
232-
)
233-
# Accept either explicit UV members or structural presence of all subprojects
234-
if members:
235-
norm = {str(Path(m)) for m in members}
236-
return {
237-
"openhands-sdk",
238-
"openhands-tools",
239-
"openhands-workspace",
240-
"openhands-agent-server",
241-
}.issubset(norm)
242-
return all((d / p).exists() for p in _EXPECTED)
243-
244-
245-
def _climb(start: Path) -> Path | None:
246-
cur = start.resolve()
247-
if not cur.is_dir():
248-
cur = cur.parent
249-
while True:
250-
if _is_workspace_root(cur):
251-
return cur
252-
if cur.parent == cur:
253-
return None
254-
cur = cur.parent
255-
256-
257-
def _default_sdk_project_root() -> Path:
258-
"""
259-
Resolve top-level OpenHands UV workspace root:
260-
261-
Order:
262-
1) Walk up from CWD
263-
2) Walk up from this file location
264-
265-
Reject anything in site/dist-packages (installed wheels).
266-
"""
267-
site_markers = ("site-packages", "dist-packages")
268-
269-
def validate(p: Path, src: str) -> Path:
270-
if any(s in str(p) for s in site_markers):
271-
raise RuntimeError(
272-
f"{src}: points inside site-packages; need the source checkout."
273-
)
274-
root = _climb(p) or p
275-
if not _is_workspace_root(root):
276-
raise RuntimeError(
277-
f"{src}: couldn't find the OpenHands UV workspace root "
278-
f"starting at '{p}'.\n\n"
279-
"Expected setup (repo root):\n"
280-
" pyproject.toml # has [tool.uv.workspace] with members\n"
281-
" openhands-sdk/pyproject.toml\n"
282-
" openhands-tools/pyproject.toml\n"
283-
" openhands-workspace/pyproject.toml\n"
284-
" openhands-agent-server/pyproject.toml\n\n"
285-
"Fix:\n"
286-
" - Run from anywhere inside the repo."
287-
)
288-
return root
289-
290-
if root := _climb(Path.cwd()):
291-
return validate(root, "CWD discovery")
292-
293-
try:
294-
here = Path(__file__).resolve()
295-
if root := _climb(here):
296-
return validate(root, "__file__ discovery")
297-
except NameError:
298-
pass
299-
300-
# Final, user-facing guidance
301-
raise RuntimeError(
302-
"Could not resolve the OpenHands UV workspace root.\n\n"
303-
"Expected repo layout:\n"
304-
" pyproject.toml (with [tool.uv.workspace].members "
305-
"including openhands/* subprojects)\n"
306-
" openhands-sdk/pyproject.toml\n"
307-
" openhands-tools/pyproject.toml\n"
308-
" openhands-workspace/pyproject.toml\n"
309-
" openhands-agent-server/pyproject.toml\n\n"
310-
"Run this from inside the repo."
311-
)
310+
_DEFAULT_GIT_REF, _DEFAULT_GIT_SHA = _git_info()
311+
_DEFAULT_PACKAGE_VERSION = _package_version()
312312

313313

314314
class BuildOptions(BaseModel):
315315
base_image: str = Field(default="nikolaik/python-nodejs:python3.12-nodejs22")
316-
sdk_project_root: Path = Field(
317-
default_factory=_default_sdk_project_root,
318-
description="Path to OpenHands SDK root. Auto if None.",
319-
)
320316
custom_tags: str = Field(
321317
default="", description="Comma-separated list of custom tags."
322318
)
@@ -330,13 +326,42 @@ class BuildOptions(BaseModel):
330326
default=None,
331327
description="Architecture suffix (e.g., 'amd64', 'arm64') to append to tags",
332328
)
329+
include_base_tag: bool = Field(
330+
default=True,
331+
description=(
332+
"Whether to include the automatically generated base tag "
333+
"based on git SHA and base image name in all_tags output."
334+
),
335+
)
333336
include_versioned_tag: bool = Field(
334337
default=False,
335338
description=(
336339
"Whether to include the versioned tag (e.g., v1.0.0_...) in all_tags "
337340
"output. Should only be True for release builds."
338341
),
339342
)
343+
git_sha: str = Field(
344+
default=_DEFAULT_GIT_SHA,
345+
description="Git commit SHA.We will need it to tag the built image.",
346+
)
347+
git_ref: str = Field(default=_DEFAULT_GIT_REF)
348+
sdk_project_root: Path = Field(
349+
default_factory=_default_sdk_project_root,
350+
description="Path to OpenHands SDK root. Auto if None.",
351+
)
352+
sdk_version: str = Field(
353+
default=_DEFAULT_PACKAGE_VERSION,
354+
description=(
355+
"SDK package version. "
356+
"We will need it to tag the built image. "
357+
"Note this is only used if include_versioned_tag is True "
358+
"(e.g., at each release)."
359+
),
360+
)
361+
362+
@property
363+
def short_sha(self) -> str:
364+
return self.git_sha[:7] if self.git_sha != "unknown" else "unknown"
340365

341366
@field_validator("target")
342367
@classmethod
@@ -355,19 +380,19 @@ def base_image_slug(self) -> str:
355380

356381
@property
357382
def versioned_tag(self) -> str:
358-
return f"v{PACKAGE_VERSION}_{self.base_image_slug}"
383+
return f"v{self.sdk_version}_{self.base_image_slug}"
359384

360385
@property
361386
def base_tag(self) -> str:
362-
return f"{SHORT_SHA}-{self.base_image_slug}"
387+
return f"{self.short_sha}-{self.base_image_slug}"
363388

364389
@property
365390
def cache_tags(self) -> tuple[str, str]:
366391
base = f"buildcache-{self.target}-{self.base_image_slug}"
367-
if GIT_REF in ("main", "refs/heads/main"):
392+
if self.git_ref in ("main", "refs/heads/main"):
368393
return f"{base}-main", base
369-
elif GIT_REF != "unknown":
370-
return f"{base}-{_sanitize_branch(GIT_REF)}", base
394+
elif self.git_ref != "unknown":
395+
return f"{base}-{_sanitize_branch(self.git_ref)}", base
371396
else:
372397
return base, base
373398

@@ -378,16 +403,14 @@ def all_tags(self) -> list[str]:
378403

379404
# Use git commit SHA for commit-based tags
380405
for t in self.custom_tag_list:
381-
tags.append(f"{self.image}:{SHORT_SHA}-{t}{arch_suffix}")
406+
tags.append(f"{self.image}:{self.short_sha}-{t}{arch_suffix}")
382407

383-
if GIT_REF in ("main", "refs/heads/main"):
408+
if self.git_ref in ("main", "refs/heads/main"):
384409
for t in self.custom_tag_list:
385410
tags.append(f"{self.image}:main-{t}{arch_suffix}")
386411

387-
# Always include base tag as default
388-
tags.append(f"{self.image}:{self.base_tag}{arch_suffix}")
389-
390-
# Only include versioned tag if requested (for releases)
412+
if self.include_base_tag:
413+
tags.append(f"{self.image}:{self.base_tag}{arch_suffix}")
391414
if self.include_versioned_tag:
392415
tags.append(f"{self.image}:{self.versioned_tag}{arch_suffix}")
393416

@@ -574,8 +597,8 @@ def build(opts: BuildOptions) -> list[str]:
574597
f"for platforms='{opts.platforms if push else 'local-arch'}'"
575598
)
576599
logger.info(
577-
f"[build] Git ref='{GIT_REF}' sha='{GIT_SHA}' "
578-
f"package_version='{PACKAGE_VERSION}'"
600+
f"[build] Git ref='{opts.git_ref}' sha='{opts.git_sha}' "
601+
f"package_version='{opts.sdk_version}'"
579602
)
580603
logger.info(f"[build] Cache tag: {cache_tag}")
581604

@@ -772,7 +795,7 @@ def _write_gha_outputs(
772795
fh.write("\n".join(tags_list) + "\n")
773796
fh.write("EOF\n")
774797

775-
_write_gha_outputs(opts.image, SHORT_SHA, opts.versioned_tag, tags)
798+
_write_gha_outputs(opts.image, opts.short_sha, opts.versioned_tag, tags)
776799
return 0
777800

778801

0 commit comments

Comments
 (0)