Skip to content
Open
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
42 changes: 42 additions & 0 deletions .github/workflows/docker-build.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
name: Docker Build & Push

on:
workflow_dispatch:
inputs:
variant:
description: 'Build variant'
required: true
type: choice
options:
- primary
- cu129-arm64
- cu13-arm64
- debug
dockerfile:
description: 'Path to dockerfile (e.g. docker/Dockerfile.dev)'
required: false
default: 'docker/Dockerfile'
type: string

jobs:
build-and-push:
runs-on: self-hosted
steps:
- name: Checkout repository
uses: actions/checkout@v4

- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
with:
driver-opts: |
image=moby/buildkit:latest
network=host

- name: Login to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}

- name: Build and push
run: python docker/build.py --variant ${{ inputs.variant }} --dockerfile ${{ inputs.dockerfile }}
133 changes: 133 additions & 0 deletions docker/build.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
#!/usr/bin/env python3
"""Build and push Miles Docker images.

Replaces the justfile with a single script that handles all build variants.

Usage:
python docker/build.py --variant primary
python docker/build.py --variant cu129-arm64
python docker/build.py --variant cu13-arm64
python docker/build.py --variant debug
python docker/build.py --variant primary --dry-run
"""

import os
import subprocess
from enum import Enum
from pathlib import Path

import typer

CACHE_DIR = "/tmp/miles-docker-cache"
Copy link
Contributor

Choose a reason for hiding this comment

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

security-medium medium

The use of a hardcoded, predictable directory in /tmp for the Docker build cache is insecure. Since /tmp is world-writable, a local attacker could pre-create this directory or use symlinks to trick the script (especially if run with elevated privileges, which is common for Docker builds) into writing data to arbitrary locations on the filesystem. Consider using a user-specific cache directory.

Suggested change
CACHE_DIR = "/tmp/miles-docker-cache"
CACHE_DIR = os.path.expanduser("~/.cache/miles-docker-cache")


VARIANTS = {
"primary": {
"image": "radixark/miles",
"tag_postfix": "",
"build_args": {},
"tag_latest": True,
},
"cu129-arm64": {
"image": "radixark/miles",
"tag_postfix": "-cu129-arm64",
"build_args": {
"SGLANG_IMAGE_TAG": "v0.5.5.post3-cu129-arm64",
"ENABLE_SGLANG_PATCH": "0",
},
"tag_latest": False,
},
"cu13-arm64": {
"image": "radixark/miles",
"tag_postfix": "-cu13-arm64",
"build_args": {
"SGLANG_IMAGE_TAG": "dev-arm64-cu13-20251122",
"ENABLE_CUDA_13": "1",
"ENABLE_SGLANG_PATCH": "0",
},
"tag_latest": False,
},
"debug": {
"image": "radixark/miles-test",
"tag_postfix": "",
"build_args": {},
"tag_latest": True,
},
}


def get_version(repo_root: Path) -> str:
version_file = repo_root / "docker" / "version.txt"
return version_file.read_text().strip()


def run(cmd: list[str], dry_run: bool) -> None:
print(f"+ {' '.join(cmd)}", flush=True)
Copy link
Contributor

Choose a reason for hiding this comment

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

security-medium medium

The script prints the full Docker command, including sensitive proxy credentials (HTTP_PROXY, HTTPS_PROXY), which are exposed in logs. This is a security vulnerability. Additionally, using ' '.join(cmd) can misrepresent the command for logging if arguments contain spaces. It's more robust to use shlex.join() for correct quoting and improved debuggability. Consider masking sensitive values before printing and ensure shlex is imported.

Suggested change
print(f"+ {' '.join(cmd)}", flush=True)
print(f"+ {shlex.join(cmd)}", flush=True)

if dry_run:
return
subprocess.run(cmd, check=True)


def build_and_push(variant: str, dry_run: bool, dockerfile: str) -> None:
config = VARIANTS[variant]
repo_root = Path(__file__).resolve().parent.parent
Copy link
Contributor

Choose a reason for hiding this comment

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

medium

For better code structure and to make functions more self-contained, consider defining repo_root as a module-level constant.

  1. Add this after your imports:
    REPO_ROOT = Path(__file__).resolve().parent.parent
  2. Update get_version to use this constant and remove its repo_root parameter:
    def get_version() -> str:
        version_file = REPO_ROOT / "docker" / "version.txt"
        return version_file.read_text().strip()
  3. Finally, you can remove this line (repo_root = ...) and call get_version() without arguments on line 72.


version = get_version(repo_root)
image = config["image"]
image_tag = f"{version}{config['tag_postfix']}"
full_tag = f"{image}:{image_tag}"

cache_key = f"{CACHE_DIR}/{variant}"

# Build command using buildx with cache and --push
cmd = [
"docker", "buildx", "build",
"-f", dockerfile,
# "--cache-from", f"type=local,src={cache_key}",
# "--cache-to", f"type=local,dest={cache_key},mode=max",
# "--push",
]

# Proxy args (pass through if set in environment)
for env_var, arg_name in [
("http_proxy", "HTTP_PROXY"),
("https_proxy", "HTTPS_PROXY"),
]:
value = os.environ.get(env_var, "")
if value:
cmd += ["--build-arg", f"{arg_name}={value}"]
Comment on lines +91 to +97
Copy link
Contributor

Choose a reason for hiding this comment

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

medium

The current proxy handling only checks for lowercase environment variables (http_proxy, https_proxy). It is a common convention to also support uppercase versions (HTTP_PROXY, HTTPS_PROXY). To make the script more robust, you should check for both.

Suggested change
for env_var, arg_name in [
("http_proxy", "HTTP_PROXY"),
("https_proxy", "HTTPS_PROXY"),
]:
value = os.environ.get(env_var, "")
if value:
cmd += ["--build-arg", f"{arg_name}={value}"]
for arg_name in ["HTTP_PROXY", "HTTPS_PROXY"]:
value = os.environ.get(arg_name.lower()) or os.environ.get(arg_name)
if value:
cmd += ["--build-arg", f"{arg_name}={value}"]


cmd += ["--build-arg", "NO_PROXY=localhost,127.0.0.1"]

# Variant-specific build args
for key, value in config["build_args"].items():
cmd += ["--build-arg", f"{key}={value}"]

# Tags
cmd += ["-t", full_tag]
if config["tag_latest"]:
cmd += ["-t", f"{image}:latest"]

# Context is repo root
cmd += ["."]

print(f"\n=== Building and pushing {full_tag} ===", flush=True)
run(cmd, dry_run)


class Variant(str, Enum):
primary = "primary"
cu129_arm64 = "cu129-arm64"
cu13_arm64 = "cu13-arm64"
debug = "debug"


def main(
variant: Variant = typer.Option(..., help="Build variant to use."),
dockerfile: str = typer.Option("docker/Dockerfile", help="Path to the Dockerfile."),
dry_run: bool = typer.Option(False, help="Print commands without executing them."),
) -> None:
build_and_push(variant.value, dry_run, dockerfile)


if __name__ == "__main__":
typer.run(main)
Loading