Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 17 additions & 9 deletions openhands-agent-server/openhands/agent_server/docker/build.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

- Targets: binary | binary-minimal | source | source-minimal
- Multi-tagging via CUSTOM_TAGS (comma-separated)
- Versioned tag includes primary custom tag: v{SDK}_{BASE_SLUG}
- Versioned tags for custom tags: {SDK_VERSION}-{CUSTOM_TAG}
- Branch-scoped cache keys
- CI (push) vs local (load) behavior
- sdist-based builds: Uses `uv build` to create clean build contexts
Expand Down Expand Up @@ -379,8 +379,12 @@ def base_image_slug(self) -> str:
return _base_slug(self.base_image)

@property
def versioned_tag(self) -> str:
return f"v{self.sdk_version}_{self.base_image_slug}"
def versioned_tags(self) -> list[str]:
"""
Generate simple version tags for each custom tag variant.
Returns tags like: 1.2.0-python, 1.2.0-java, 1.2.0-golang
"""
return [f"{self.sdk_version}-{t}" for t in self.custom_tag_list]

@property
def base_tag(self) -> str:
Expand Down Expand Up @@ -412,7 +416,8 @@ def all_tags(self) -> list[str]:
if self.include_base_tag:
tags.append(f"{self.image}:{self.base_tag}{arch_suffix}")
if self.include_versioned_tag:
tags.append(f"{self.image}:{self.versioned_tag}{arch_suffix}")
for versioned_tag in self.versioned_tags:
tags.append(f"{self.image}:{versioned_tag}{arch_suffix}")

# Append target suffix for clarity (binary is default, no suffix needed)
if self.target != "binary":
Expand Down Expand Up @@ -734,7 +739,7 @@ def main(argv: list[str]) -> int:
fh.write(f"build_context={ctx}\n")
fh.write(f"dockerfile={ctx / 'Dockerfile'}\n")
fh.write(f"tags_csv={','.join(opts.all_tags)}\n")
fh.write(f"versioned_tag={opts.versioned_tag}\n")
fh.write(f"versioned_tags_csv={','.join(opts.versioned_tags)}\n")
fh.write(f"base_image_slug={opts.base_image_slug}\n")
logger.info("[build] Wrote outputs to $GITHUB_OUTPUT")

Expand Down Expand Up @@ -773,13 +778,16 @@ def main(argv: list[str]) -> int:

# --- expose outputs for GitHub Actions ---
def _write_gha_outputs(
image: str, short_sha: str, versioned_tag: str, tags_list: list[str]
image: str,
short_sha: str,
versioned_tags: list[str],
tags_list: list[str],
) -> None:
"""
If running in GitHub Actions, append step outputs to $GITHUB_OUTPUT.
- image: repo/name (no tag)
- short_sha: 7-char SHA
- versioned_tag: e.g. v{SDK}_{BASE_SLUG}_{target}[ -dev ]
- versioned_tags_csv: comma-separated list of versioned tags
- tags: multiline output (one per line)
- tags_csv: single-line, comma-separated
"""
Expand All @@ -789,13 +797,13 @@ def _write_gha_outputs(
with open(out_path, "a", encoding="utf-8") as fh:
fh.write(f"image={image}\n")
fh.write(f"short_sha={short_sha}\n")
fh.write(f"versioned_tag={versioned_tag}\n")
fh.write(f"versioned_tags_csv={','.join(versioned_tags)}\n")
fh.write(f"tags_csv={','.join(tags_list)}\n")
fh.write("tags<<EOF\n")
fh.write("\n".join(tags_list) + "\n")
fh.write("EOF\n")

_write_gha_outputs(opts.image, opts.short_sha, opts.versioned_tag, tags)
_write_gha_outputs(opts.image, opts.short_sha, opts.versioned_tags, tags)
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@OpenHands please check in this repo if our image building workflow servers.yml will enable these version tags on "git tags"

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm on it! xingyaoww can track my progress at all-hands.dev

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Based on my analysis of the repository, here's the answer to your question:

Summary: Version Tags on Git Tags - Current State

Question: Will the image building workflow server.yml enable version tags on git tags?

Answer: The workflow partially supports versioned tags, but has a critical gap that prevents the feature from working correctly.

What Currently Works ✅

  1. Trigger: The workflow triggers correctly on any git tag push (line 7-8)
  2. Build Detection: The build job detects git tags and enables the --versioned-tag flag (lines 242-244)
  3. Architecture-Specific Images: Individual architecture images ARE pushed with version tags:
    • ghcr.io/openhands/agent-server:1.2.0-python-amd64
    • ghcr.io/openhands/agent-server:1.2.0-python-arm64
    • etc.

Critical Gap ❌

The merge-manifests job does NOT create multi-arch manifests for versioned tags.

Currently, the merge-manifests job only creates multi-arch manifests for:

  • SHA-based tags: b642962-python
  • Latest tags (main branch): latest-python

It does NOT create the intended simple version tags:

  • 1.2.0-pythonMISSING
  • 1.2.0-javaMISSING
  • 1.2.0-golangMISSING

Impact

Users cannot pull images using the simple version tags described in your PR:

# This will NOT work (manifest doesn't exist):
docker pull ghcr.io/openhands/agent-server:1.2.0-python

# Users would need to specify architecture explicitly:
docker pull ghcr.io/openhands/agent-server:1.2.0-python-amd64

Required Fix

The merge-manifests job (lines 355-406) needs to be updated to:

  1. Detect when triggered by a git tag
  2. Extract the SDK version from build artifacts (versioned_tags_csv is available in GITHUB_OUTPUT)
  3. Create multi-arch manifests for each versioned tag

The build.py script already generates and outputs versioned_tags_csv, but this information needs to be:

  • Saved to the build-info artifacts (currently only tags is saved, not versioned_tags)
  • Used by the merge-manifests job to create the versioned multi-arch manifests

In summary: The versioned tags feature is incomplete. It pushes architecture-specific images but doesn't create the multi-arch manifests needed for users to pull images with simple version tags like 1.2.0-python.

View full conversation

return 0


Expand Down
143 changes: 143 additions & 0 deletions tests/agent_server/test_docker_build.py
Original file line number Diff line number Diff line change
Expand Up @@ -259,3 +259,146 @@ def test_base_slug_edge_case_exact_max_len():
result = _base_slug("python:3.12", max_len=15)
assert result == "python_tag_3.12"
assert len(result) == 15


def test_versioned_tags_single_custom_tag():
"""Test versioned_tags with a single custom tag."""
from openhands.agent_server.docker.build import BuildOptions

opts = BuildOptions(
custom_tags="python",
sdk_version="1.2.0",
include_versioned_tag=True,
)

versioned_tags = opts.versioned_tags
assert versioned_tags == ["1.2.0-python"]


def test_versioned_tags_multiple_custom_tags():
"""Test versioned_tags with multiple custom tags."""
from openhands.agent_server.docker.build import BuildOptions

opts = BuildOptions(
custom_tags="python,java,golang",
sdk_version="1.2.0",
include_versioned_tag=True,
)

versioned_tags = opts.versioned_tags
assert versioned_tags == ["1.2.0-python", "1.2.0-java", "1.2.0-golang"]


def test_versioned_tags_no_custom_tags():
"""Test versioned_tags when no custom tags are provided."""
from openhands.agent_server.docker.build import BuildOptions

opts = BuildOptions(
custom_tags="",
sdk_version="1.2.0",
include_versioned_tag=True,
)

versioned_tags = opts.versioned_tags
assert versioned_tags == []


def test_all_tags_includes_versioned_tags():
"""Test that all_tags includes versioned tags when enabled."""
from openhands.agent_server.docker.build import BuildOptions

opts = BuildOptions(
custom_tags="python,java",
sdk_version="1.2.0",
git_sha="abc1234567890",
include_versioned_tag=True,
include_base_tag=False, # Simplify by excluding base tag
)

all_tags = opts.all_tags

# Should include commit-based tags
assert "ghcr.io/openhands/agent-server:abc1234-python" in all_tags
assert "ghcr.io/openhands/agent-server:abc1234-java" in all_tags

# Should include versioned tags
assert "ghcr.io/openhands/agent-server:1.2.0-python" in all_tags
assert "ghcr.io/openhands/agent-server:1.2.0-java" in all_tags


def test_all_tags_excludes_versioned_tags_when_disabled():
"""Test that all_tags excludes versioned tags when disabled."""
from openhands.agent_server.docker.build import BuildOptions

opts = BuildOptions(
custom_tags="python",
sdk_version="1.2.0",
git_sha="abc1234567890",
include_versioned_tag=False,
include_base_tag=False,
)

all_tags = opts.all_tags

# Should include commit-based tag
assert "ghcr.io/openhands/agent-server:abc1234-python" in all_tags

# Should NOT include versioned tags
assert "ghcr.io/openhands/agent-server:1.2.0-python" not in all_tags


def test_all_tags_with_arch_suffix():
"""Test that versioned tags include architecture suffix."""
from openhands.agent_server.docker.build import BuildOptions

opts = BuildOptions(
custom_tags="python",
sdk_version="1.2.0",
git_sha="abc1234567890",
arch="amd64",
include_versioned_tag=True,
include_base_tag=False,
)

all_tags = opts.all_tags

# Should include versioned tag with arch suffix
assert "ghcr.io/openhands/agent-server:1.2.0-python-amd64" in all_tags
assert "ghcr.io/openhands/agent-server:abc1234-python-amd64" in all_tags


def test_all_tags_with_target_suffix():
"""Test that versioned tags include target suffix for non-binary targets."""
from openhands.agent_server.docker.build import BuildOptions

opts = BuildOptions(
custom_tags="python",
sdk_version="1.2.0",
git_sha="abc1234567890",
target="source",
include_versioned_tag=True,
include_base_tag=False,
)

all_tags = opts.all_tags

# Should include versioned tag with target suffix
assert "ghcr.io/openhands/agent-server:1.2.0-python-source" in all_tags
assert "ghcr.io/openhands/agent-server:abc1234-python-source" in all_tags


def test_versioned_tags_format_without_v_prefix():
"""Test that versioned tags don't include 'v' prefix."""
from openhands.agent_server.docker.build import BuildOptions

opts = BuildOptions(
custom_tags="python",
sdk_version="1.2.0",
include_versioned_tag=True,
)

versioned_tags = opts.versioned_tags

# Should be "1.2.0-python", not "v1.2.0-python"
assert versioned_tags == ["1.2.0-python"]
assert not any(tag.startswith("v") for tag in versioned_tags)
Loading