Skip to content

Commit 204d3a4

Browse files
refactor: git SHA-based Docker tags by default, versioned tags only on releases (#1088)
Co-authored-by: openhands <[email protected]>
1 parent e616bf4 commit 204d3a4

File tree

3 files changed

+384
-53
lines changed

3 files changed

+384
-53
lines changed

.github/workflows/server.yml

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ on:
55
push:
66
branches: [main]
77
tags:
8-
- build-docker
8+
- '*' # Trigger on any tag (e.g., 1.0.0, 1.0.0a5, build-docker)
99
pull_request:
1010
branches: [main]
1111
workflow_dispatch:
@@ -237,9 +237,12 @@ jobs:
237237
238238
# Generate build context and tags with arch suffix
239239
# build.py now handles architecture tagging internally via --arch flag
240-
uv run ./openhands-agent-server/openhands/agent_server/docker/build.py \
241-
--build-ctx-only \
242-
--arch ${{ matrix.arch }}
240+
# Add --versioned-tag when triggered by a git tag (e.g., v1.0.0)
241+
BUILD_CMD="uv run ./openhands-agent-server/openhands/agent_server/docker/build.py --build-ctx-only --arch ${{ matrix.arch }}"
242+
if [[ "${{ github.ref }}" == refs/tags/* ]]; then
243+
BUILD_CMD="$BUILD_CMD --versioned-tag"
244+
fi
245+
eval "$BUILD_CMD"
243246
244247
# Alias tags_csv output to tags for the build action
245248
TAGS=$(grep '^tags_csv=' $GITHUB_OUTPUT | cut -d= -f2-)

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

Lines changed: 116 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -102,31 +102,76 @@ def pump(stream, sink: list[str], log_fn, prefix: str) -> None:
102102
return result
103103

104104

105-
def _base_slug(image: str) -> str:
106-
return image.replace("/", "_s_").replace(":", "_tag_")
107-
108-
109105
def _sanitize_branch(ref: str) -> str:
110106
ref = re.sub(r"^refs/heads/", "", ref or "unknown")
111107
return re.sub(r"[^a-zA-Z0-9.-]+", "-", ref).lower()
112108

113109

114-
def _sdk_version() -> str:
115-
from importlib.metadata import version
110+
def _base_slug(image: str, max_len: int = 64) -> str:
111+
"""
112+
If the slug is too long, keep the most identifiable parts:
113+
- repository name (last path segment)
114+
- tag (if present)
115+
Then append a short digest for uniqueness.
116+
Format preserved with existing separators: '_s_' for '/', '_tag_' for ':'.
117+
118+
Example:
119+
'ghcr.io_s_org_s/very-long-repo_tag_v1.2.3-extra'
120+
-> 'very-long-repo_tag_v1.2.3-<digest>'
121+
"""
122+
base_slug = image.replace("/", "_s_").replace(":", "_tag_")
123+
124+
if len(base_slug) <= max_len:
125+
return base_slug
126+
127+
digest = hashlib.sha256(base_slug.encode()).hexdigest()[:12]
128+
suffix = f"-{digest}"
129+
130+
# Parse components from the slug form
131+
if "_tag_" in base_slug:
132+
left, tag = base_slug.split("_tag_", 1)
133+
else:
134+
left, tag = base_slug, ""
135+
136+
parts = left.split("_s_") if left else []
137+
repo = parts[-1] if parts else left # last path segment is the repo
138+
139+
# Reconstruct a compact, identifiable core: "<repo>[_tag_<tag>]"
140+
ident = repo + (f"_tag_{tag}" if tag else "")
141+
142+
# Fit within budget, reserving space for the digest suffix
143+
visible_budget = max_len - len(suffix)
144+
assert visible_budget > 0, (
145+
f"max_len too small to fit digest suffix with length {len(suffix)}"
146+
)
116147

117-
return version("openhands-sdk")
148+
kept = ident[:visible_budget]
149+
return kept + suffix
118150

119151

120152
def _git_info() -> tuple[str, str, str]:
121-
git_sha = os.environ.get("GITHUB_SHA")
153+
"""
154+
Get git info (ref, sha, short_sha) for the current working directory.
155+
156+
Priority order for SHA:
157+
1. SDK_SHA - Explicit override (e.g., for submodule builds)
158+
2. GITHUB_SHA - GitHub Actions environment
159+
3. git rev-parse HEAD - Local development
160+
161+
Priority order for REF:
162+
1. SDK_REF - Explicit override (e.g., for submodule builds)
163+
2. GITHUB_REF - GitHub Actions environment
164+
3. git symbolic-ref HEAD - Local development
165+
"""
166+
git_sha = os.environ.get("SDK_SHA") or os.environ.get("GITHUB_SHA")
122167
if not git_sha:
123168
try:
124169
git_sha = _run(["git", "rev-parse", "--verify", "HEAD"]).stdout.strip()
125170
except subprocess.CalledProcessError:
126171
git_sha = "unknown"
127172
short_sha = git_sha[:7] if git_sha != "unknown" else "unknown"
128173

129-
git_ref = os.environ.get("GITHUB_REF")
174+
git_ref = os.environ.get("SDK_REF") or os.environ.get("GITHUB_REF")
130175
if not git_ref:
131176
try:
132177
git_ref = _run(
@@ -137,8 +182,30 @@ def _git_info() -> tuple[str, str, str]:
137182
return git_ref, git_sha, short_sha
138183

139184

185+
def _package_version() -> str:
186+
"""
187+
Get the semantic version from the openhands-sdk package.
188+
This is used for versioned tags during releases.
189+
"""
190+
try:
191+
from importlib.metadata import version
192+
193+
return version("openhands-sdk")
194+
except Exception:
195+
# If package is not installed, try reading from pyproject.toml
196+
try:
197+
sdk_root = _default_sdk_project_root()
198+
pyproject_path = sdk_root / "openhands-sdk" / "pyproject.toml"
199+
if pyproject_path.exists():
200+
cfg = tomllib.loads(pyproject_path.read_text(encoding="utf-8"))
201+
return cfg.get("project", {}).get("version", "unknown")
202+
except Exception:
203+
pass
204+
return "unknown"
205+
206+
140207
GIT_REF, GIT_SHA, SHORT_SHA = _git_info()
141-
SDK_VERSION = _sdk_version()
208+
PACKAGE_VERSION = _package_version()
142209

143210

144211
# --- options ---
@@ -263,6 +330,13 @@ class BuildOptions(BaseModel):
263330
default=None,
264331
description="Architecture suffix (e.g., 'amd64', 'arm64') to append to tags",
265332
)
333+
include_versioned_tag: bool = Field(
334+
default=False,
335+
description=(
336+
"Whether to include the versioned tag (e.g., v1.0.0_...) in all_tags "
337+
"output. Should only be True for release builds."
338+
),
339+
)
266340

267341
@field_validator("target")
268342
@classmethod
@@ -280,46 +354,16 @@ def base_image_slug(self) -> str:
280354
return _base_slug(self.base_image)
281355

282356
@property
283-
def is_dev(self) -> bool:
284-
return self.target in ("source", "source-minimal")
357+
def versioned_tag(self) -> str:
358+
return f"v{PACKAGE_VERSION}_{self.base_image_slug}"
285359

286360
@property
287-
def versioned_tag(self) -> str:
288-
return f"v{SDK_VERSION}_{self.base_image_slug}_{self.target}"
361+
def base_tag(self) -> str:
362+
return f"{SHORT_SHA}-{self.base_image_slug}"
289363

290364
@property
291365
def cache_tags(self) -> tuple[str, str]:
292-
# Docker image tags have a 128-character limit.
293-
# If the base slug is too long, hash it to create a shorter unique identifier.
294-
MAX_TAG_LENGTH = 128
295-
base_slug = self.base_image_slug
296-
297-
# Reserve space for prefix, branch, and separators
298-
prefix = f"buildcache-{self.target}-"
299-
branch_suffix = (
300-
f"-{_sanitize_branch(GIT_REF)}"
301-
if GIT_REF not in ("main", "refs/heads/main", "unknown")
302-
else ""
303-
)
304-
main_suffix = "-main" if GIT_REF in ("main", "refs/heads/main") else ""
305-
306-
# Calculate available space for base_slug
307-
reserved = len(prefix) + max(len(branch_suffix), len(main_suffix))
308-
available = MAX_TAG_LENGTH - reserved
309-
310-
# If base_slug is too long, use a hash
311-
if len(base_slug) > available:
312-
# Use first 8 chars of SHA256 hash for uniqueness while keeping it short
313-
hash_digest = hashlib.sha256(base_slug.encode()).hexdigest()[:12]
314-
base_slug_short = hash_digest
315-
logger.debug(
316-
f"[build] Base image slug too long ({len(base_slug)} chars), "
317-
f"using hash: {base_slug_short}"
318-
)
319-
else:
320-
base_slug_short = base_slug
321-
322-
base = f"{prefix}{base_slug_short}"
366+
base = f"buildcache-{self.target}-{self.base_image_slug}"
323367
if GIT_REF in ("main", "refs/heads/main"):
324368
return f"{base}-main", base
325369
elif GIT_REF != "unknown":
@@ -332,14 +376,24 @@ def all_tags(self) -> list[str]:
332376
tags: list[str] = []
333377
arch_suffix = f"-{self.arch}" if self.arch else ""
334378

379+
# Use git commit SHA for commit-based tags
335380
for t in self.custom_tag_list:
336381
tags.append(f"{self.image}:{SHORT_SHA}-{t}{arch_suffix}")
382+
337383
if GIT_REF in ("main", "refs/heads/main"):
338384
for t in self.custom_tag_list:
339385
tags.append(f"{self.image}:main-{t}{arch_suffix}")
340-
tags.append(f"{self.image}:{self.versioned_tag}{arch_suffix}")
341-
if self.is_dev:
342-
tags = [f"{t}-dev" for t in tags]
386+
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)
391+
if self.include_versioned_tag:
392+
tags.append(f"{self.image}:{self.versioned_tag}{arch_suffix}")
393+
394+
# Append target suffix for clarity (binary is default, no suffix needed)
395+
if self.target != "binary":
396+
tags = [f"{t}-{self.target}" for t in tags]
343397
return tags
344398

345399

@@ -519,7 +573,10 @@ def build(opts: BuildOptions) -> list[str]:
519573
f"custom_tags='{opts.custom_tags}' from base='{opts.base_image}' "
520574
f"for platforms='{opts.platforms if push else 'local-arch'}'"
521575
)
522-
logger.info(f"[build] Git ref='{GIT_REF}' sha='{GIT_SHA}' version='{SDK_VERSION}'")
576+
logger.info(
577+
f"[build] Git ref='{GIT_REF}' sha='{GIT_SHA}' "
578+
f"package_version='{PACKAGE_VERSION}'"
579+
)
523580
logger.info(f"[build] Cache tag: {cache_tag}")
524581

525582
try:
@@ -609,6 +666,14 @@ def main(argv: list[str]) -> int:
609666
action="store_true",
610667
help="Only create the clean build context directory and print its path.",
611668
)
669+
parser.add_argument(
670+
"--versioned-tag",
671+
action="store_true",
672+
help=(
673+
"Include versioned tag (e.g., v1.0.0_...) in output. "
674+
"Should only be used for release builds."
675+
),
676+
)
612677

613678
args = parser.parse_args(argv)
614679

@@ -636,6 +701,7 @@ def main(argv: list[str]) -> int:
636701
push=None, # Not relevant for build-ctx-only
637702
sdk_project_root=sdk_project_root,
638703
arch=args.arch or None,
704+
include_versioned_tag=args.versioned_tag,
639705
)
640706

641707
# If running in GitHub Actions, write outputs directly to GITHUB_OUTPUT
@@ -678,6 +744,7 @@ def main(argv: list[str]) -> int:
678744
push=push,
679745
sdk_project_root=sdk_project_root,
680746
arch=args.arch or None,
747+
include_versioned_tag=args.versioned_tag,
681748
)
682749
tags = build(opts)
683750

0 commit comments

Comments
 (0)