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+
46145def _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
185288def _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
314314class 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