Skip to content
Merged
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
7 changes: 5 additions & 2 deletions .github/workflows/publish-studio-release.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,9 @@ jobs:
- name: Set up uv
uses: astral-sh/setup-uv@v6

- name: Validate frozen Studio runtime lock
run: uv lock --check

- name: Build and validate Studio bundle
env:
PIP_INDEX_URL: https://mirrors.aliyun.com/pypi/simple/
Expand All @@ -85,7 +88,7 @@ jobs:
output_dir="$RUNNER_TEMP/studio-release-output"
version="$(TZ=Asia/Shanghai date +%Y%m%d%H%M%S)"
export output_dir version
uv run --group dev python - <<'PY'
uv run --frozen --group dev python - <<'PY'
import json
import os
from pathlib import Path
Expand Down Expand Up @@ -133,7 +136,7 @@ jobs:
runtime_venv="$RUNNER_TEMP/studio-release-runtime"
export package_dir runtime_venv

uv run --group dev python - <<'PY'
uv run --frozen --group dev python - <<'PY'
import os
from pathlib import Path

Expand Down
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,8 @@ ipython_config.py
# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control.
# This is especially recommended for binary packages to ensure reproducibility, and is more
# commonly ignored for libraries.
uv.lock
# VeADK also publishes Studio as a locked offline application. Keep the root
# uv.lock tracked so a release built from an exact Git SHA is reproducible.

# poetry
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
Expand Down
26 changes: 25 additions & 1 deletion frontend/service/studio_release_server/offline_runtime.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,24 @@
_PYTHON_VERSION = "3.12"
_PYTHON_ABI = "cp312"
_PIP_VERSION = "25.2"
_CANONICAL_PYPI_INDEX = "https://pypi.org/simple"
_INDEX_ENVIRONMENT_KEYS = (
"UV_DEFAULT_INDEX",
"UV_INDEX",
"UV_INDEX_URL",
"UV_EXTRA_INDEX_URL",
"PIP_INDEX_URL",
"PIP_EXTRA_INDEX_URL",
)


def _lock_check_environment(environment: Mapping[str, str]) -> dict[str, str]:
"""Validate the committed lock against its canonical package index."""
lock_environment = dict(environment)
for key in _INDEX_ENVIRONMENT_KEYS:
lock_environment.pop(key, None)
lock_environment["UV_DEFAULT_INDEX"] = _CANONICAL_PYPI_INDEX
return lock_environment


def build_studio_offline_runtime(
Expand All @@ -61,12 +79,18 @@ def build_studio_offline_runtime(
uv = shutil.which("uv", path=(environment or os.environ).get("PATH"))
if uv is None:
raise ValueError("uv is required to build the Studio offline runtime.")
build_environment = dict(environment or os.environ)
_run(
[uv, "lock", "--check"],
cwd=source_root,
environment=_lock_check_environment(build_environment),
failure="Studio runtime lock is stale.",
)

package_dir.mkdir(parents=True, exist_ok=True)
wheelhouse = package_dir / STUDIO_RUNTIME_WHEELHOUSE
wheelhouse.mkdir()
runtime_lock = package_dir / STUDIO_RUNTIME_LOCK
build_environment = dict(environment or os.environ)

with tempfile.TemporaryDirectory(prefix="veadk_studio_runtime_") as tmp:
workspace = Path(tmp)
Expand Down
66 changes: 66 additions & 0 deletions tests/cli/test_studio_offline_runtime.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,25 @@
from frontend.service.studio_release_server import offline_runtime


def test_lock_check_environment_uses_canonical_pypi() -> None:
environment = offline_runtime._lock_check_environment(
{
"PATH": "/usr/bin",
"UV_DEFAULT_INDEX": "https://mirror.invalid/simple",
"UV_INDEX": "private=https://mirror.invalid/simple",
"UV_INDEX_URL": "https://legacy.invalid/simple",
"UV_EXTRA_INDEX_URL": "https://extra.invalid/simple",
"PIP_INDEX_URL": "https://pip.invalid/simple",
"PIP_EXTRA_INDEX_URL": "https://pip-extra.invalid/simple",
}
)

assert environment == {
"PATH": "/usr/bin",
"UV_DEFAULT_INDEX": "https://pypi.org/simple",
}


def test_linux_runtime_lock_uses_target_markers(tmp_path: Path) -> None:
exported = tmp_path / "exported.txt"
exported.write_text(
Expand All @@ -36,6 +55,52 @@ def test_linux_runtime_lock_uses_target_markers(tmp_path: Path) -> None:
assert target.read_text(encoding="utf-8") == ("common==1\nlinux-only==2\n")


def test_build_offline_runtime_requires_committed_lock(tmp_path: Path) -> None:
source_root = tmp_path / "source"
source_root.mkdir()
veadk_wheel = tmp_path / "veadk_python-1.0-py3-none-any.whl"
veadk_wheel.write_bytes(b"veadk")

with pytest.raises(ValueError, match="requires uv.lock"):
offline_runtime.build_studio_offline_runtime(
source_root,
tmp_path / "package",
veadk_wheel=veadk_wheel,
dependency_sources=(),
environment={"PATH": ""},
)


def test_build_offline_runtime_rejects_stale_lock(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
source_root = tmp_path / "source"
source_root.mkdir()
(source_root / "uv.lock").write_text("stale", encoding="utf-8")
veadk_wheel = tmp_path / "veadk_python-1.0-py3-none-any.whl"
veadk_wheel.write_bytes(b"veadk")
monkeypatch.setattr(
offline_runtime.shutil, "which", lambda *_args, **_kwargs: "/usr/bin/uv"
)

def reject_lock(command: list[str], **_kwargs: object) -> None:
assert command[1:] == ["lock", "--check"]
raise subprocess.CalledProcessError(2, command)

monkeypatch.setattr(offline_runtime.subprocess, "run", reject_lock)

with pytest.raises(ValueError, match="Studio runtime lock is stale"):
offline_runtime.build_studio_offline_runtime(
source_root,
tmp_path / "package",
veadk_wheel=veadk_wheel,
dependency_sources=(),
environment={"PATH": "/usr/bin"},
)
assert not (tmp_path / "package").exists()


def test_build_offline_runtime_creates_local_only_contract(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
Expand Down Expand Up @@ -95,6 +160,7 @@ def fake_run(command: list[str], **_kwargs: object) -> subprocess.CompletedProce
assert "linux-only==2 --hash=sha256:" in runtime_lock
assert "tos==1 --hash=sha256:" in runtime_lock
assert (package_dir / "wheelhouse" / veadk_wheel.name).read_bytes() == b"veadk"
assert commands[0][1:] == ["lock", "--check"]
download = next(command for command in commands if "download" in command)
assert "--only-binary=:all:" in download
assert "manylinux_2_17_x86_64" in download
Expand Down
32 changes: 32 additions & 0 deletions tests/test_studio_release_lock_contract.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
# Copyright (c) 2025 Beijing Volcano Engine Technology Co., Ltd. and/or its affiliates.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

from pathlib import Path


def test_release_workflow_requires_committed_frozen_uv_lock() -> None:
repository = Path(__file__).parents[1]
workflow = (
repository / ".github" / "workflows" / "publish-studio-release.yaml"
).read_text(encoding="utf-8")
ignored = {
line.strip()
for line in (repository / ".gitignore").read_text(encoding="utf-8").splitlines()
if line.strip() and not line.lstrip().startswith("#")
}

assert (repository / "uv.lock").is_file()
assert "uv.lock" not in ignored
assert "uv lock --check" in workflow
assert workflow.count("uv run --frozen --group dev python") == 2
Loading
Loading