diff --git a/.github/workflows/_docker-build-and-publish.yml b/.github/workflows/_docker-build-and-publish.yml index d2b8246fe..ba55dc939 100644 --- a/.github/workflows/_docker-build-and-publish.yml +++ b/.github/workflows/_docker-build-and-publish.yml @@ -45,6 +45,10 @@ jobs: if: github.repository == 'sgl-project/sglang' environment: ${{ inputs.use_environment || null }} runs-on: x64-docker-build-node + env: + TAG_CONFIG: ${{ inputs.tag_config }} + SGL_VERSION: ${{ inputs.sgl_version }} + IMAGE_REPO: ${{ inputs.image_repo }} outputs: digest-cu129: ${{ steps.build-cu129.outputs.digest }} digest-cu130: ${{ steps.build-cu130.outputs.digest }} @@ -60,6 +64,22 @@ jobs: with: ref: ${{ inputs.checkout_ref || github.ref }} + - name: Compute Docker build metadata args + run: | + set -euo pipefail + BUILD_COMMIT="$(git rev-parse HEAD)" + BUILD_URL="${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}" + for CUDA_VARIANT in cu129 cu130; do + python3 scripts/ci/utils/docker_build_metadata_args.py \ + --cuda "${CUDA_VARIANT}" \ + --tag-config "${TAG_CONFIG}" \ + --image-repo "${IMAGE_REPO}" \ + --sgl-version "${SGL_VERSION}" \ + --build-commit "${BUILD_COMMIT}" \ + --build-url "${BUILD_URL}" \ + > "/tmp/docker-metadata-${CUDA_VARIANT}.args" + done + - name: Free disk space uses: jlumbroso/free-disk-space@main with: @@ -90,9 +110,10 @@ jobs: id: build-cu129 run: | VERSION_ARG="" - if [ -n "${{ inputs.sgl_version }}" ]; then - VERSION_ARG="--build-arg SGL_VERSION=${{ inputs.sgl_version }}" + if [ -n "${SGL_VERSION}" ]; then + VERSION_ARG="--build-arg SGL_VERSION=${SGL_VERSION}" fi + mapfile -t METADATA_ARGS < /tmp/docker-metadata-cu129.args docker buildx build \ --target ${{ inputs.docker_target }} \ @@ -103,6 +124,7 @@ jobs: --build-arg BUILD_TYPE=all \ --build-arg GRACE_BLACKWELL=0 \ --build-arg INSTALL_FLASHINFER_JIT_CACHE=1 \ + "${METADATA_ARGS[@]}" \ ${VERSION_ARG} \ ${{ inputs.extra_build_args }} \ --metadata-file /tmp/metadata-cu129.json \ @@ -117,9 +139,10 @@ jobs: id: build-cu130 run: | VERSION_ARG="" - if [ -n "${{ inputs.sgl_version }}" ]; then - VERSION_ARG="--build-arg SGL_VERSION=${{ inputs.sgl_version }}" + if [ -n "${SGL_VERSION}" ]; then + VERSION_ARG="--build-arg SGL_VERSION=${SGL_VERSION}" fi + mapfile -t METADATA_ARGS < /tmp/docker-metadata-cu130.args docker buildx build \ --target ${{ inputs.docker_target }} \ @@ -130,6 +153,7 @@ jobs: --build-arg BUILD_TYPE=all \ --build-arg GRACE_BLACKWELL=0 \ --build-arg INSTALL_FLASHINFER_JIT_CACHE=1 \ + "${METADATA_ARGS[@]}" \ ${VERSION_ARG} \ ${{ inputs.extra_build_args }} \ --metadata-file /tmp/metadata-cu130.json \ @@ -144,6 +168,10 @@ jobs: if: github.repository == 'sgl-project/sglang' environment: ${{ inputs.use_environment || null }} runs-on: arm-docker-build-node + env: + TAG_CONFIG: ${{ inputs.tag_config }} + SGL_VERSION: ${{ inputs.sgl_version }} + IMAGE_REPO: ${{ inputs.image_repo }} outputs: digest-cu129: ${{ steps.build-cu129.outputs.digest }} digest-cu130: ${{ steps.build-cu130.outputs.digest }} @@ -159,6 +187,22 @@ jobs: with: ref: ${{ inputs.checkout_ref || github.ref }} + - name: Compute Docker build metadata args + run: | + set -euo pipefail + BUILD_COMMIT="$(git rev-parse HEAD)" + BUILD_URL="${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}" + for CUDA_VARIANT in cu129 cu130; do + python3 scripts/ci/utils/docker_build_metadata_args.py \ + --cuda "${CUDA_VARIANT}" \ + --tag-config "${TAG_CONFIG}" \ + --image-repo "${IMAGE_REPO}" \ + --sgl-version "${SGL_VERSION}" \ + --build-commit "${BUILD_COMMIT}" \ + --build-url "${BUILD_URL}" \ + > "/tmp/docker-metadata-${CUDA_VARIANT}.args" + done + - name: Prune Docker to reclaim disk space run: | docker buildx prune --filter "until=72h" -f @@ -178,9 +222,10 @@ jobs: id: build-cu129 run: | VERSION_ARG="" - if [ -n "${{ inputs.sgl_version }}" ]; then - VERSION_ARG="--build-arg SGL_VERSION=${{ inputs.sgl_version }}" + if [ -n "${SGL_VERSION}" ]; then + VERSION_ARG="--build-arg SGL_VERSION=${SGL_VERSION}" fi + mapfile -t METADATA_ARGS < /tmp/docker-metadata-cu129.args docker buildx build \ --target ${{ inputs.docker_target }} \ @@ -191,6 +236,7 @@ jobs: --build-arg BUILD_TYPE=all \ --build-arg GRACE_BLACKWELL=1 \ --build-arg INSTALL_FLASHINFER_JIT_CACHE=1 \ + "${METADATA_ARGS[@]}" \ ${VERSION_ARG} \ ${{ inputs.extra_build_args }} \ --metadata-file /tmp/metadata-cu129.json \ @@ -205,9 +251,10 @@ jobs: id: build-cu130 run: | VERSION_ARG="" - if [ -n "${{ inputs.sgl_version }}" ]; then - VERSION_ARG="--build-arg SGL_VERSION=${{ inputs.sgl_version }}" + if [ -n "${SGL_VERSION}" ]; then + VERSION_ARG="--build-arg SGL_VERSION=${SGL_VERSION}" fi + mapfile -t METADATA_ARGS < /tmp/docker-metadata-cu130.args docker buildx build \ --target ${{ inputs.docker_target }} \ @@ -218,6 +265,7 @@ jobs: --build-arg BUILD_TYPE=all \ --build-arg GRACE_BLACKWELL=1 \ --build-arg INSTALL_FLASHINFER_JIT_CACHE=1 \ + "${METADATA_ARGS[@]}" \ ${VERSION_ARG} \ ${{ inputs.extra_build_args }} \ --metadata-file /tmp/metadata-cu130.json \ diff --git a/docker/Dockerfile b/docker/Dockerfile index e6a413074..7db2ffa07 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -639,6 +639,21 @@ RUN --mount=type=cache,target=/root/.cache/pip \ # Set workspace directory WORKDIR /sgl-workspace/sglang +# Keep build provenance at the end so metadata changes do not invalidate build layers. +ARG SGLANG_BUILD_COMMIT=unknown +ARG SGLANG_BUILD_URL= +ARG SGLANG_IMAGE_TAG=local/sglang:dev +ENV SGLANG_BUILD_COMMIT=${SGLANG_BUILD_COMMIT:-unknown} \ + SGLANG_BUILD_URL=${SGLANG_BUILD_URL:-} \ + SGLANG_IMAGE_TAG=${SGLANG_IMAGE_TAG:-local/sglang:dev} +LABEL org.opencontainers.image.source="https://github.com/sgl-project/sglang" \ + org.opencontainers.image.revision="${SGLANG_BUILD_COMMIT}" \ + org.opencontainers.image.version="${SGLANG_IMAGE_TAG}" \ + org.opencontainers.image.url="${SGLANG_BUILD_URL}" \ + ai.sglang.build.commit="${SGLANG_BUILD_COMMIT}" \ + ai.sglang.build.url="${SGLANG_BUILD_URL}" \ + ai.sglang.image.tag="${SGLANG_IMAGE_TAG}" + ######################################################## ########## Runtime Image ############################## ######################################################## @@ -782,5 +797,20 @@ RUN ln -sf /usr/lib/$(uname -m)-linux-gnu/libmlx5.so.1 /usr/lib/$(uname -m)-linu WORKDIR /sgl-workspace/sglang +# Keep build provenance at the end so metadata changes do not invalidate build layers. +ARG SGLANG_BUILD_COMMIT=unknown +ARG SGLANG_BUILD_URL= +ARG SGLANG_IMAGE_TAG=local/sglang:dev +ENV SGLANG_BUILD_COMMIT=${SGLANG_BUILD_COMMIT:-unknown} \ + SGLANG_BUILD_URL=${SGLANG_BUILD_URL:-} \ + SGLANG_IMAGE_TAG=${SGLANG_IMAGE_TAG:-local/sglang:dev} +LABEL org.opencontainers.image.source="https://github.com/sgl-project/sglang" \ + org.opencontainers.image.revision="${SGLANG_BUILD_COMMIT}" \ + org.opencontainers.image.version="${SGLANG_IMAGE_TAG}" \ + org.opencontainers.image.url="${SGLANG_BUILD_URL}" \ + ai.sglang.build.commit="${SGLANG_BUILD_COMMIT}" \ + ai.sglang.build.url="${SGLANG_BUILD_URL}" \ + ai.sglang.image.tag="${SGLANG_IMAGE_TAG}" + # Default command CMD ["/bin/bash"] diff --git a/scripts/ci/utils/docker_build_metadata_args.py b/scripts/ci/utils/docker_build_metadata_args.py new file mode 100644 index 000000000..ea83d6449 --- /dev/null +++ b/scripts/ci/utils/docker_build_metadata_args.py @@ -0,0 +1,119 @@ +import argparse +import datetime +import json +import sys + +MOVING_TAGS = {"dev", "dev-cu13", "latest"} + + +def render_tag_template(tag: str, version: str, date: str, short_sha: str) -> str: + return ( + tag.replace("{version}", version) + .replace("{date}", date) + .replace("{short_sha}", short_sha) + ) + + +def is_moving_tag(tag: str) -> bool: + return tag in MOVING_TAGS or tag.startswith("latest-") + + +def select_tag( + tag_config: str, cuda: str, version: str, date: str, short_sha: str +) -> str: + entries = json.loads(tag_config) + for entry in entries: + if entry.get("cuda") != cuda: + continue + + tags = [ + render_tag_template(tag, version, date, short_sha) + for tag in entry.get("tags", []) + ] + if not tags: + raise ValueError(f"No tags configured for CUDA variant {cuda}") + + for tag in tags: + if not is_moving_tag(tag): + return tag + return tags[0] + + raise ValueError(f"CUDA variant {cuda} not found in tag_config") + + +def build_arg_tokens( + *, + cuda: str, + tag_config: str, + image_repo: str, + version: str, + build_commit: str, + build_url: str, + date: str, + short_sha: str, +) -> list[str]: + image_tag = select_tag(tag_config, cuda, version, date, short_sha) + build_args = { + "SGLANG_BUILD_COMMIT": build_commit, + "SGLANG_BUILD_URL": build_url, + "SGLANG_IMAGE_TAG": f"{image_repo}:{image_tag}", + } + + tokens = [] + for key, value in build_args.items(): + tokens.extend(["--build-arg", f"{key}={value}"]) + return tokens + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser( + description="Emit docker build arguments for SGLang image metadata." + ) + parser.add_argument("--cuda", required=True, help="CUDA variant from tag_config.") + parser.add_argument("--tag-config", required=True, help="Docker tag JSON config.") + parser.add_argument("--image-repo", required=True, help="Docker image repository.") + parser.add_argument("--sgl-version", default="", help="SGLang release version.") + parser.add_argument( + "--build-commit", + required=True, + help="Commit checked out for the Docker build.", + ) + parser.add_argument("--build-url", default="", help="CI run URL.") + parser.add_argument( + "--date", + default=datetime.datetime.now(datetime.timezone.utc).strftime("%Y%m%d"), + help="Date used for {date} tag templates.", + ) + parser.add_argument( + "--short-sha", + default="", + help="Short SHA used for {short_sha}; defaults to build commit prefix.", + ) + return parser.parse_args() + + +def main() -> int: + args = parse_args() + short_sha = args.short_sha or args.build_commit[:8] + + try: + tokens = build_arg_tokens( + cuda=args.cuda, + tag_config=args.tag_config, + image_repo=args.image_repo, + version=args.sgl_version, + build_commit=args.build_commit, + build_url=args.build_url, + date=args.date, + short_sha=short_sha, + ) + except (json.JSONDecodeError, ValueError) as exc: + print(f"error: {exc}", file=sys.stderr) + return 1 + + print("\n".join(tokens)) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/test/registered/unit/tools/test_docker_build_metadata_args.py b/test/registered/unit/tools/test_docker_build_metadata_args.py new file mode 100644 index 000000000..c4ee4c042 --- /dev/null +++ b/test/registered/unit/tools/test_docker_build_metadata_args.py @@ -0,0 +1,202 @@ +import importlib.util +import json +import subprocess +import unittest +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parents[4] +CI_REGISTER_PATH = REPO_ROOT / "python" / "sglang" / "test" / "ci" / "ci_register.py" +HELPER_PATH = REPO_ROOT / "scripts" / "ci" / "utils" / "docker_build_metadata_args.py" +DOCKERFILE_PATH = REPO_ROOT / "docker" / "Dockerfile" +WORKFLOW_PATH = REPO_ROOT / ".github" / "workflows" / "_docker-build-and-publish.yml" + + +def _load_module(name, path): + spec = importlib.util.spec_from_file_location(name, path) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +register_cpu_ci = _load_module("ci_register", CI_REGISTER_PATH).register_cpu_ci +register_cpu_ci(est_time=0, suite="stage-a-test-cpu") + + +class TestDockerBuildMetadataArgs(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.helper = _load_module("docker_build_metadata_args", HELPER_PATH) + + def run_helper( + self, + *, + cuda: str, + tag_config: list[dict[str, object]], + image_repo: str = "lmsysorg/sglang", + version: str = "0.6.0", + build_commit: str = "abcdef1234567890", + build_url: str = "https://github.com/sgl-project/sglang/actions/runs/1", + date: str = "20260429", + ) -> list[str]: + result = subprocess.run( + [ + "python3", + str(HELPER_PATH), + "--cuda", + cuda, + "--tag-config", + json.dumps(tag_config), + "--image-repo", + image_repo, + "--sgl-version", + version, + "--build-commit", + build_commit, + "--build-url", + build_url, + "--date", + date, + ], + check=True, + stdout=subprocess.PIPE, + text=True, + ) + return result.stdout.splitlines() + + @staticmethod + def option_values(args: list[str], option: str) -> list[str]: + return [args[i + 1] for i, arg in enumerate(args[:-1]) if arg == option] + + def build_args(self, args: list[str]) -> dict[str, str]: + values = {} + for value in self.option_values(args, "--build-arg"): + key, arg_value = value.split("=", 1) + values[key] = arg_value + return values + + def test_release_metadata_prefers_versioned_tag(self): + args = self.run_helper( + cuda="cu129", + tag_config=[ + {"cuda": "cu129", "tags": ["v{version}", "latest"]}, + {"cuda": "cu130", "tags": ["v{version}-cu130", "latest-cu130"]}, + ], + ) + + self.assertEqual( + self.build_args(args), + { + "SGLANG_BUILD_COMMIT": "abcdef1234567890", + "SGLANG_BUILD_URL": ( + "https://github.com/sgl-project/sglang/actions/runs/1" + ), + "SGLANG_IMAGE_TAG": "lmsysorg/sglang:v0.6.0", + }, + ) + + def test_runtime_metadata_uses_custom_repo_and_runtime_tag(self): + args = self.run_helper( + cuda="cu130", + image_repo="lmsysorg/sglang-staging", + tag_config=[ + {"cuda": "cu129", "tags": ["v{version}-runtime", "latest-runtime"]}, + { + "cuda": "cu130", + "tags": ["v{version}-cu130-runtime", "latest-cu130-runtime"], + }, + ], + ) + + self.assertEqual( + self.build_args(args)["SGLANG_IMAGE_TAG"], + "lmsysorg/sglang-staging:v0.6.0-cu130-runtime", + ) + + def test_dev_nightly_metadata_prefers_unique_tag_from_checked_out_commit(self): + args = self.run_helper( + cuda="cu129", + version="", + build_commit="1234567890abcdef", + tag_config=[ + {"cuda": "cu129", "tags": ["dev", "nightly-dev-{date}-{short_sha}"]}, + { + "cuda": "cu130", + "tags": ["dev-cu13", "nightly-dev-cu13-{date}-{short_sha}"], + }, + ], + ) + + self.assertEqual( + self.build_args(args)["SGLANG_IMAGE_TAG"], + "lmsysorg/sglang:nightly-dev-20260429-12345678", + ) + self.assertEqual( + self.build_args(args)["SGLANG_BUILD_COMMIT"], + "1234567890abcdef", + ) + + def test_custom_dev_tag_is_treated_as_specific(self): + args = self.run_helper( + cuda="cu130", + version="", + tag_config=[ + {"cuda": "cu129", "tags": ["dev-my-test"]}, + {"cuda": "cu130", "tags": ["dev-cu13-my-test"]}, + ], + ) + + self.assertEqual( + self.build_args(args)["SGLANG_IMAGE_TAG"], + "lmsysorg/sglang:dev-cu13-my-test", + ) + + def test_missing_cuda_entry_fails(self): + with self.assertRaisesRegex(ValueError, "cu130"): + self.helper.select_tag( + json.dumps([{"cuda": "cu129", "tags": ["v{version}"]}]), + "cu130", + "0.6.0", + "20260429", + "abcdef12", + ) + + def test_final_dockerfile_stages_embed_metadata_contract(self): + dockerfile = DOCKERFILE_PATH.read_text() + framework_stage = dockerfile.split("FROM framework AS framework_final", 1)[ + 1 + ].split("FROM nvidia/cuda:${CUDA_VERSION}-cudnn-devel-ubuntu24.04 AS runtime")[ + 0 + ] + runtime_stage = dockerfile.split( + "FROM nvidia/cuda:${CUDA_VERSION}-cudnn-devel-ubuntu24.04 AS runtime", 1 + )[1] + + for stage in (framework_stage, runtime_stage): + for expected in ( + "ARG SGLANG_BUILD_COMMIT=unknown", + "ARG SGLANG_BUILD_URL=", + "ARG SGLANG_IMAGE_TAG=local/sglang:dev", + "SGLANG_BUILD_COMMIT=${SGLANG_BUILD_COMMIT:-unknown}", + "SGLANG_BUILD_URL=${SGLANG_BUILD_URL:-}", + "SGLANG_IMAGE_TAG=${SGLANG_IMAGE_TAG:-local/sglang:dev}", + 'org.opencontainers.image.source="https://github.com/sgl-project/sglang"', + 'org.opencontainers.image.revision="${SGLANG_BUILD_COMMIT}"', + 'org.opencontainers.image.version="${SGLANG_IMAGE_TAG}"', + 'org.opencontainers.image.url="${SGLANG_BUILD_URL}"', + 'ai.sglang.build.commit="${SGLANG_BUILD_COMMIT}"', + 'ai.sglang.build.url="${SGLANG_BUILD_URL}"', + 'ai.sglang.image.tag="${SGLANG_IMAGE_TAG}"', + ): + self.assertIn(expected, stage) + + def test_shared_docker_workflow_uses_checked_out_commit(self): + workflow = WORKFLOW_PATH.read_text() + + self.assertIn("git rev-parse HEAD", workflow) + self.assertIn("scripts/ci/utils/docker_build_metadata_args.py", workflow) + self.assertIn("mapfile -t METADATA_ARGS", workflow) + self.assertIn('"${METADATA_ARGS[@]}"', workflow) + + +if __name__ == "__main__": + unittest.main()