From cd4e212d0b022fcddc606abde598508adb92c623 Mon Sep 17 00:00:00 2001 From: "yanan.zhangyn" Date: Thu, 17 Sep 2026 10:43:53 +0800 Subject: [PATCH 1/4] fix(studio): preserve locked wheel bytes in thin releases --- .../harness-sidecar-release-gate.yaml | 3 + .github/workflows/publish-studio-release.yaml | 22 +- frontend/README.md | 8 + .../studio_release_server/offline_runtime.py | 23 +++ .../studio_release_server/publisher.py | 16 +- tests/test_harness_sidecar_release_gate.py | 1 + tests/test_studio_release_cold_start.py | 195 ++++++++++++++++++ tests/test_studio_release_lock_contract.py | 3 +- tests/test_studio_release_server.py | 2 + tests/test_studio_release_workflow.py | 7 +- 10 files changed, 270 insertions(+), 10 deletions(-) create mode 100644 tests/test_studio_release_cold_start.py diff --git a/.github/workflows/harness-sidecar-release-gate.yaml b/.github/workflows/harness-sidecar-release-gate.yaml index 540e33d32..da5c27bb1 100644 --- a/.github/workflows/harness-sidecar-release-gate.yaml +++ b/.github/workflows/harness-sidecar-release-gate.yaml @@ -23,6 +23,7 @@ on: - 'tests/test_harness_sidecar_release_gate.py' - 'tests/test_cloud.py' - 'tests/test_studio_release_server.py' + - 'tests/test_studio_release_cold_start.py' - 'tests/test_studio_release_workflow.py' - 'veadk/cli/generated_agent_codegen.py' - 'veadk/cli/generated_agent_mcp.py' @@ -60,6 +61,7 @@ on: - 'tests/test_harness_sidecar_release_gate.py' - 'tests/test_cloud.py' - 'tests/test_studio_release_server.py' + - 'tests/test_studio_release_cold_start.py' - 'tests/test_studio_release_workflow.py' - 'veadk/cli/generated_agent_codegen.py' - 'veadk/cli/generated_agent_mcp.py' @@ -149,6 +151,7 @@ jobs: ( python -m pytest -q \ tests/test_studio_release_server.py::test_release_server_agentkit_cli_pin_matches_veadk \ + tests/test_studio_release_cold_start.py \ tests/test_studio_release_server.py::test_publisher_repairs_missing_agentkit_cli_before_manifest \ tests/test_studio_release_server.py::test_release_server_zip_normalizes_runtime_file_permissions \ tests/test_studio_release_server.py::test_stage_deployment_uses_frontend_service_package \ diff --git a/.github/workflows/publish-studio-release.yaml b/.github/workflows/publish-studio-release.yaml index 0f1398314..f4f77027c 100644 --- a/.github/workflows/publish-studio-release.yaml +++ b/.github/workflows/publish-studio-release.yaml @@ -33,6 +33,9 @@ on: - 'veadk/**' - 'pyproject.toml' - 'uv.lock' + - 'tests/test_studio_release_cold_start.py' + - 'tests/test_studio_release_workflow.py' + - 'tests/test_studio_release_lock_contract.py' - 'README.md' - 'LICENSE' @@ -103,6 +106,13 @@ jobs: - name: Validate frozen Studio runtime lock run: uv lock --check + - name: Validate cold-start and thin release contracts + run: >- + uv run --frozen --group dev python -m pytest -q + tests/test_studio_release_cold_start.py + tests/test_studio_release_workflow.py + tests/test_studio_release_lock_contract.py + - name: Build and validate Studio bundle run: | set -euo pipefail @@ -124,7 +134,7 @@ jobs: STUDIO_DEPENDENCY_SOURCES, ) from veadk.cli.studio_package import build_frontend_assets - from veadk.cli.studio_release import build_studio_release + from frontend.service.studio_release_server.publisher import build_studio_release def prepare_dependency(dependency, destination): @@ -187,6 +197,9 @@ jobs: changelog=changelog, frontend_assets=frontend_assets, dependency_wheels=dependency_inputs, + env=dict(os.environ), + thin=True, + provider="volcengine", ) print(f"Bundle built in {time.monotonic() - started:.1f}s", flush=True) print( @@ -201,11 +214,14 @@ jobs: ) ) PY - bundles=("$output_dir"/studio-bundle-*.zip) + bundles=("$output_dir"/studio-bundle-??????????????.zip) + thin_bundles=("$output_dir"/studio-bundle-??????????????-thin.zip) manifests=("$output_dir"/manifest-*.json) test "${#bundles[@]}" -eq 1 + test "${#thin_bundles[@]}" -eq 1 test "${#manifests[@]}" -eq 1 unzip -t "${bundles[0]}" + unzip -t "${thin_bundles[0]}" - name: Simulate customer update and smoke-test Studio env: @@ -220,7 +236,7 @@ jobs: test "$(python3 -c 'import sys; print(f"{sys.version_info.major}.{sys.version_info.minor}")')" = "3.12" COLD_START_DEADLINE_SECONDS=60 STUDIO_MAX_BUNDLE_BYTES=$((256 * 1024 * 1024)) - bundle=("$RUNNER_TEMP"/studio-release-output/studio-bundle-*.zip) + bundle=("$RUNNER_TEMP"/studio-release-output/studio-bundle-??????????????.zip) test "${#bundle[@]}" -eq 1 test "$(stat -c %s "${bundle[0]}")" -lt "$STUDIO_MAX_BUNDLE_BYTES" diff --git a/frontend/README.md b/frontend/README.md index 58ef4b6d6..528b7ef68 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -1037,6 +1037,14 @@ and `latest.json`. Configure only `STUDIO_RELEASE_SERVER_URL` and `STUDIO_RELEASE_SERVER_API_KEY` as GitHub Secrets; GitHub receives no TOS credentials. +Release verification uses the Release Server publisher to build both full and +thin bundles. Full bundles retain checked-hash bytecode for cold starts. Thin +bundles keep upstream dependency wheels byte-for-byte identical to `uv.lock`; +only the local VeADK wheel is precompiled. Each bundle has its own hash-pinned +runtime lock. Public provenance and license checks run before any artifact is +published. Changes to this build logic require deploying the Release Server +before starting a new release; merging the source alone does not update it. + The Release Server runtime and deployment assets are isolated from the public Python package under `frontend/service/studio_release_server`. After changing the service, deploy it from the repository root: diff --git a/frontend/service/studio_release_server/offline_runtime.py b/frontend/service/studio_release_server/offline_runtime.py index ffce8c33f..b395aefec 100644 --- a/frontend/service/studio_release_server/offline_runtime.py +++ b/frontend/service/studio_release_server/offline_runtime.py @@ -106,6 +106,7 @@ def build_studio_offline_runtime( dependency_sources: Sequence[Path], environment: Mapping[str, str] | None = None, optimize_cold_start: bool = False, + thin_package_dir: Path | None = None, ) -> str: """Bundle every locked Linux dependency and return offline requirements.""" lock_source = source_root / "uv.lock" @@ -230,6 +231,28 @@ def build_studio_offline_runtime( if optimize_cold_start: if sys.implementation.name != "cpython" or sys.version_info[:2] != (3, 12): raise ValueError("Studio cold-start optimization requires CPython 3.12.") + if thin_package_dir is not None: + # Public artifacts must retain the exact PyPI bytes recorded in uv.lock. + # Snapshot before full-bundle optimization, with an independent hash lock. + shutil.copytree(wheelhouse, thin_package_dir) + thin_veadk = thin_package_dir / staged_veadk.name + if optimize_cold_start: + _augment_checked_hash_wheel( + thin_veadk, _COLD_START_WHEEL_ROOTS["veadk-python"] + ) + thin_lock = thin_package_dir / STUDIO_RUNTIME_LOCK + shutil.copy2(runtime_lock, thin_lock) + _pin_runtime_lock_to_wheelhouse(thin_lock, thin_package_dir, thin_veadk) + thin_requirements = build_studio_offline_requirements( + thin_package_dir, wheel_prefix="./" + ) + _verify_offline_resolution( + thin_package_dir, + thin_requirements, + uv=uv, + environment=build_environment, + ) + if optimize_cold_start: _enhance_studio_cold_start_wheels(wheelhouse) _pin_runtime_lock_to_wheelhouse(runtime_lock, wheelhouse, staged_veadk) for wheel in sorted(wheelhouse.glob("*.whl")): diff --git a/frontend/service/studio_release_server/publisher.py b/frontend/service/studio_release_server/publisher.py index 6a8aa0d0e..a538aa0aa 100644 --- a/frontend/service/studio_release_server/publisher.py +++ b/frontend/service/studio_release_server/publisher.py @@ -951,6 +951,8 @@ def _build_local_requirements( frontend_assets: Path, dependency_wheels: Path, env: Mapping[str, str], + *, + thin_package_dir: Path | None = None, ) -> str: wheel_source = package_dir / "wheel-source" stage_studio_wheel_source(source_root, frontend_assets, wheel_source) @@ -984,16 +986,20 @@ def _build_local_requirements( if path.name != _AGENTKIT_CLI_ARCHIVE ) try: - return build_studio_offline_runtime( + requirements = build_studio_offline_runtime( source_root, package_dir, veadk_wheel=built_wheels[0], dependency_sources=dependency_sources, environment=env, optimize_cold_start=True, + thin_package_dir=thin_package_dir, ) except ValueError as error: raise StudioPublisherError(str(error)) from error + if thin_package_dir is not None: + shutil.copy2(package_dir / _AGENTKIT_CLI_ARCHIVE, thin_package_dir) + return requirements def _studio_run_script(*, thin: bool = False) -> str: @@ -1428,12 +1434,14 @@ def build_studio_release( ) package_dir = workspace / "package" package_dir.mkdir() + thin_package_dir = workspace / "thin-package" if thin else None requirements = _build_local_requirements( source_root, package_dir, resolved_frontend, dependency_wheels, env, + thin_package_dir=thin_package_dir, ) (package_dir / "run.sh").write_text( _studio_run_script(), @@ -1445,15 +1453,15 @@ def build_studio_release( bundle = output_dir / f"studio-bundle-{version}.zip" _zip_directory(package_dir, bundle) thin_bundle: Path | None = None - if thin: + if thin_package_dir is not None: runtime_epoch, _artifact_dir = stage_studio_thin_runtime( source_root, - package_dir, + thin_package_dir, output_dir, provider=provider, ) thin_bundle = output_dir / f"studio-bundle-{version}-thin.zip" - _zip_directory(package_dir, thin_bundle) + _zip_directory(thin_package_dir, thin_bundle) ensure_studio_bundle_agentkit_cli(bundle, dependency_wheels) content = bundle.read_bytes() thin_content = thin_bundle.read_bytes() if thin_bundle is not None else b"" diff --git a/tests/test_harness_sidecar_release_gate.py b/tests/test_harness_sidecar_release_gate.py index bf1f3e6dc..5a6afaeb4 100644 --- a/tests/test_harness_sidecar_release_gate.py +++ b/tests/test_harness_sidecar_release_gate.py @@ -71,6 +71,7 @@ def test_sidecar_release_gate_runs_backend_and_frontend_in_parallel() -> None: in (backend_run) ) assert "test_release_server_agentkit_cli_pin_matches_veadk" in backend_run + assert "tests/test_studio_release_cold_start.py" in backend_run assert "test_smoke_gate_survives_platform_entrypoint_mode_normalization" in ( backend_run ) diff --git a/tests/test_studio_release_cold_start.py b/tests/test_studio_release_cold_start.py new file mode 100644 index 000000000..8ae14a3f1 --- /dev/null +++ b/tests/test_studio_release_cold_start.py @@ -0,0 +1,195 @@ +# Copyright (c) 2025 Beijing Volcano Engine Technology Co., Ltd. and/or its affiliates. +# SPDX-License-Identifier: Apache-2.0 + +"""Exercise cold-start rewriting and thin publication together, without networking.""" + +from __future__ import annotations + +import hashlib +import io +import json +import shutil +import subprocess +import sys +import zipfile +from pathlib import Path +from typing import Any + +import pytest + +from frontend.service.studio_release_server import offline_runtime, publisher +from veadk.cli import studio_artifacts + + +def _wheel(name: str, roots: tuple[str, ...]) -> tuple[str, bytes]: + distribution = name.replace("-", "_") + filename = f"{distribution}-1.0-py3-none-any.whl" + metadata = f"{distribution}-1.0.dist-info" + output = io.BytesIO() + with zipfile.ZipFile(output, "w") as archive: + for root in roots: + archive.writestr(f"{root}/__init__.py", "VALUE = 1\n") + archive.writestr( + f"{metadata}/METADATA", + f"Metadata-Version: 2.4\nName: {name}\nVersion: 1.0\n" + "License-Expression: MIT\n", + ) + archive.writestr( + f"{metadata}/WHEEL", + "Wheel-Version: 1.0\nRoot-Is-Purelib: true\nTag: py3-none-any\n", + ) + archive.writestr(f"{metadata}/RECORD", "") + return filename, output.getvalue() + + +@pytest.mark.skipif( + sys.version_info[:2] != (3, 12), + reason="release bytecode requires CPython 3.12", +) +@pytest.mark.parametrize("thin,tampered", [(False, False), (True, False), (True, True)]) +def test_cold_start_release_preserves_public_wheel_provenance( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, thin: bool, tampered: bool +) -> None: + source = tmp_path / "source" + frontend = source / "frontend" + frontend.mkdir(parents=True) + (source / "veadk").mkdir() + for filename in ( + "pyproject.toml", + "README.md", + "LICENSE", + "frontend/package.json", + "frontend/package-lock.json", + "frontend/__init__.py", + "veadk/__init__.py", + ): + (source / filename).write_text("", encoding="utf-8") + # Load the real manifest contract without including it in the synthetic wheel. + monkeypatch.setattr( + publisher, + "_load_studio_artifact_contract", + lambda _root: studio_artifacts, + ) + assets = tmp_path / "assets" + assets.mkdir() + (assets / "index.html").write_text("Studio", encoding="utf-8") + inputs = tmp_path / "inputs" + inputs.mkdir() + cli = inputs / "agentkit-linux-x64.tar.gz" + cli.write_bytes(b"fixture-cli") + monkeypatch.setattr( + publisher, "_AGENTKIT_CLI_ARCHIVE_SHA256", publisher._sha256_file(cli) + ) + originals: dict[str, bytes] = {} + lock_entries: list[str] = [] + dependencies: list[str] = [] + for name, roots in offline_runtime._COLD_START_WHEEL_ROOTS.items(): + filename, content = _wheel(name, roots) + originals[filename] = content + if name == "veadk-python": + continue + dependencies.append(f"{name}==1.0\n") + lock_entries.append( + f'[[package]]\nname = "{name}"\nversion = "1.0"\n' + 'source = { registry = "https://pypi.org/simple" }\n' + 'wheels = [{ url = "https://files.pythonhosted.org/packages/' + f'{filename}", size = {len(content)}, ' + f'hash = "sha256:{hashlib.sha256(content).hexdigest()}" }}]\n' + ) + (source / "uv.lock").write_text("".join(lock_entries), encoding="utf-8") + resolutions: list[str] = [] + + def run(command: list[str], **kwargs: Any) -> subprocess.CompletedProcess[Any]: + if command[1] == "build": + target = Path(command[command.index("-o") + 1]) + filename = "veadk_python-1.0-py3-none-any.whl" + (target / filename).write_bytes(originals[filename]) + elif command[1:3] == ["lock", "--check"]: + pass + elif command[1] == "export": + Path(command[command.index("--output-file") + 1]).write_text( + "".join(dependencies), encoding="utf-8" + ) + elif "download" in command: + target = Path(command[command.index("--dest") + 1]) + for filename, content in originals.items(): + if filename.startswith("veadk_python-"): + continue + if tampered and filename.startswith("agentkit_sdk_python-"): + content += b"unexpected-download-bytes" + (target / filename).write_bytes(content) + elif command[1:3] == ["pip", "install"]: + requirements = kwargs["input"] + resolutions.append(requirements) + for line in requirements.splitlines(): + if line.startswith("./"): + filename, digest = line.split(" --hash=sha256:") + wheel = Path(kwargs["cwd"]) / filename + assert publisher._sha256_file(wheel) == digest + else: + pytest.fail(f"Unexpected build command: {command[:3]}") + return subprocess.CompletedProcess(command, 0) + + # The actual public-wheel validator and bytecode compiler are never mocked. + monkeypatch.setattr(shutil, "which", lambda *_args, **_kwargs: "/fixture/uv") + monkeypatch.setattr(subprocess, "run", run) + output = tmp_path / "output" + kwargs: dict[str, Any] = dict( + source_root=source, + output_dir=output, + version="20260917120000", + git_sha="a" * 40, + changelog=("Fix thin release",), + frontend_assets=assets, + dependency_wheels=inputs, + env={"PATH": "/fixture"}, + thin=thin, + ) + if thin and tampered: + with pytest.raises( + publisher.StudioPublisherError, match="does not match uv.lock" + ): + publisher.build_studio_release(**kwargs) + assert not list(output.glob("manifest-*.json")) + assert not list(output.glob("runtime-artifacts-*")) + return + + bundle, manifest = publisher.build_studio_release(**kwargs) + assert resolutions + with zipfile.ZipFile(bundle) as archive: + full_lock = archive.read("studio-runtime.lock").decode() + full_requirements = archive.read("requirements.txt").decode() + for filename, original in originals.items(): + content = archive.read(filename) + assert content != original + digest = hashlib.sha256(content).hexdigest() + assert f"./{filename} --hash=sha256:{digest}" in full_requirements + if not filename.startswith("veadk_python-"): + assert digest in full_lock + with zipfile.ZipFile(io.BytesIO(content)) as wheel: + assert any(name.endswith(".pyc") for name in wheel.namelist()) + if not thin: + assert manifest.runtime_epoch == "" + assert not list(output.glob("*thin*")) + return + artifacts = output / f"runtime-artifacts-{manifest.runtime_epoch}" + assert len(list(artifacts.glob("*.whl"))) == len(originals) - 1 + with zipfile.ZipFile(output / "studio-bundle-20260917120000-thin.zip") as archive: + runtime = json.loads(archive.read("studio-runtime.json")) + thin_lock = archive.read("studio-runtime.lock").decode() + for artifact in runtime["artifacts"]: + if artifact["kind"] == "wheel": + filename = artifact["filename"] + assert (artifacts / filename).read_bytes() == originals[filename] + assert artifact["sha256"] in thin_lock + veadk = "veadk_python-1.0-py3-none-any.whl" + with zipfile.ZipFile(io.BytesIO(archive.read(veadk))) as wheel: + assert any(name.endswith(".pyc") for name in wheel.namelist()) + extracted = tmp_path / "thin-extracted" + archive.extractall(extracted) + assert publisher.validate_studio_bundle_dependencies(extracted) == ( + extracted / "studio-runtime.json" + ) + publisher.validate_public_runtime_provenance( + source, sorted(artifacts.glob("*.whl")) + ) diff --git a/tests/test_studio_release_lock_contract.py b/tests/test_studio_release_lock_contract.py index 018b8d8b3..d63bf7795 100644 --- a/tests/test_studio_release_lock_contract.py +++ b/tests/test_studio_release_lock_contract.py @@ -29,4 +29,5 @@ def test_release_workflow_requires_committed_frozen_uv_lock() -> None: 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 + assert workflow.count("uv run --frozen --group dev python") == 3 + assert "tests/test_studio_release_cold_start.py" in workflow diff --git a/tests/test_studio_release_server.py b/tests/test_studio_release_server.py index d93f63470..555eb36fd 100644 --- a/tests/test_studio_release_server.py +++ b/tests/test_studio_release_server.py @@ -856,6 +856,8 @@ def _offline_runtime( "dependency==1.0\n", encoding="utf-8", ) + if _kwargs.get("thin_package_dir") is not None: + shutil.copytree(package_dir, _kwargs["thin_package_dir"]) return ( "--no-index\n" "--require-hashes\n" diff --git a/tests/test_studio_release_workflow.py b/tests/test_studio_release_workflow.py index 3304b74f6..6d69b2822 100644 --- a/tests/test_studio_release_workflow.py +++ b/tests/test_studio_release_workflow.py @@ -142,11 +142,11 @@ def test_smoke_gate_requires_unexpected_studio_exit_to_fail_closed() -> None: def test_verification_reuses_checked_inputs_and_rebuilds_current_source( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: + from frontend.service.studio_release_server import publisher from veadk.cli import ( agentkit_cli, studio_dependencies, studio_package, - studio_release, ) sources = [ @@ -200,6 +200,9 @@ def build_frontend(source: Path, destination: Path, *, changelog: Any) -> None: (destination / "index.html").write_text("current frontend") def build_bundle(**kwargs: Any) -> tuple[Path, Any]: + assert kwargs["thin"] is True + assert kwargs["provider"] == "volcengine" + assert "PATH" in kwargs["env"] assert (kwargs["frontend_assets"] / "index.html").is_file() for dependency in (*sources, artifact): content = (kwargs["dependency_wheels"] / dependency.filename).read_bytes() @@ -215,7 +218,7 @@ def build_bundle(**kwargs: Any) -> tuple[Path, Any]: monkeypatch.setattr("urllib.request.urlopen", download) monkeypatch.setattr(agentkit_cli, "download_agentkit_cli_archive", download_cli) monkeypatch.setattr(studio_package, "build_frontend_assets", build_frontend) - monkeypatch.setattr(studio_release, "build_studio_release", build_bundle) + monkeypatch.setattr(publisher, "build_studio_release", build_bundle) script = _verification_script() exec(script, {}) assert len(downloads) == 3 From 8c9e6271e4c784805c9480a597bb3414dfc88b8d Mon Sep 17 00:00:00 2001 From: "yanan.zhangyn" Date: Thu, 17 Sep 2026 10:53:30 +0800 Subject: [PATCH 2/4] fix(tests): use required Apache license header --- tests/test_studio_release_cold_start.py | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/tests/test_studio_release_cold_start.py b/tests/test_studio_release_cold_start.py index 8ae14a3f1..5ac88dee0 100644 --- a/tests/test_studio_release_cold_start.py +++ b/tests/test_studio_release_cold_start.py @@ -1,5 +1,16 @@ # Copyright (c) 2025 Beijing Volcano Engine Technology Co., Ltd. and/or its affiliates. -# SPDX-License-Identifier: Apache-2.0 +# +# 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. """Exercise cold-start rewriting and thin publication together, without networking.""" From 7cbcff8a22c4e6be39d153524e7fb5b558935c42 Mon Sep 17 00:00:00 2001 From: "yanan.zhangyn" Date: Thu, 17 Sep 2026 11:01:20 +0800 Subject: [PATCH 3/4] fix(ci): smoke-test the selected full Studio bundle --- .github/workflows/publish-studio-release.yaml | 5 ++- tests/test_studio_release_workflow.py | 35 +++++++++++++++++++ 2 files changed, 37 insertions(+), 3 deletions(-) diff --git a/.github/workflows/publish-studio-release.yaml b/.github/workflows/publish-studio-release.yaml index f4f77027c..27843e02f 100644 --- a/.github/workflows/publish-studio-release.yaml +++ b/.github/workflows/publish-studio-release.yaml @@ -294,7 +294,7 @@ jobs: runtime_venv="$RUNNER_TEMP/studio-release-runtime-${provider}" log="$RUNNER_TEMP/studio-${provider}.log" response="$RUNNER_TEMP/studio-${provider}-ui-config.json" - export package_dir + export package_dir bundle_path="${bundle[0]}" test ! -e "$package_dir" test ! -e "$runtime_venv" uv run --frozen --group dev python - <<'PY' @@ -303,8 +303,7 @@ jobs: from veadk.cli.studio_self_update import extract_studio_bundle - output_dir = Path(os.environ["RUNNER_TEMP"]) / "studio-release-output" - bundle = next(output_dir.glob("studio-bundle-*.zip")) + bundle = Path(os.environ["bundle_path"]) extract_studio_bundle(bundle, Path(os.environ["package_dir"])) PY uv venv --python 3.12 "$runtime_venv" diff --git a/tests/test_studio_release_workflow.py b/tests/test_studio_release_workflow.py index 6d69b2822..479e0bc2c 100644 --- a/tests/test_studio_release_workflow.py +++ b/tests/test_studio_release_workflow.py @@ -139,6 +139,41 @@ def test_smoke_gate_requires_unexpected_studio_exit_to_fail_closed() -> None: ) +def test_smoke_extracts_selected_full_bundle_when_thin_is_present( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + from veadk.cli import studio_self_update + + output = tmp_path / "studio-release-output" + output.mkdir() + thin = output / "studio-bundle-20260917120000-thin.zip" + full = output / "studio-bundle-20260917120000.zip" + thin.write_bytes(b"provider-specific thin bundle") + full.write_bytes(b"full bundle shared by both providers") + destination = tmp_path / "package" + monkeypatch.setenv("RUNNER_TEMP", str(tmp_path)) + monkeypatch.setenv("package_dir", str(destination)) + monkeypatch.setenv("bundle_path", str(full)) + + # Exercise an allowed directory order that exposes selecting the first ZIP. + original_glob = Path.glob + monkeypatch.setattr( + Path, "glob", lambda path, pattern: iter(sorted(original_glob(path, pattern))) + ) + extracted: list[tuple[Path, Path]] = [] + monkeypatch.setattr( + studio_self_update, + "extract_studio_bundle", + lambda archive, target: extracted.append((archive, target)), + ) + script = _smoke_script() + extraction = script.split("<<'PY'\n", 1)[1].split("\nPY\n", 1)[0] + exec(extraction, {}) + + assert extracted == [(full, destination)] + assert 'export package_dir bundle_path="${bundle[0]}"' in script + + def test_verification_reuses_checked_inputs_and_rebuilds_current_source( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: From a99268ff9b7804bb9f1be5565fffddcb8a4c146f Mon Sep 17 00:00:00 2001 From: "yanan.zhangyn" Date: Thu, 17 Sep 2026 11:22:13 +0800 Subject: [PATCH 4/4] fix(ci): use frozen uv environment for sidecar release gate --- .../harness-sidecar-release-gate.yaml | 13 +- tests/test_harness_sidecar_release_gate.py | 111 ++++++++++++++++++ 2 files changed, 121 insertions(+), 3 deletions(-) diff --git a/.github/workflows/harness-sidecar-release-gate.yaml b/.github/workflows/harness-sidecar-release-gate.yaml index da5c27bb1..93acea2ae 100644 --- a/.github/workflows/harness-sidecar-release-gate.yaml +++ b/.github/workflows/harness-sidecar-release-gate.yaml @@ -40,6 +40,7 @@ on: - 'veadk/extensions/harness/**' - 'veadk/integrations/agentkit/app.py' - 'pyproject.toml' + - 'uv.lock' pull_request: paths: - '.github/workflows/harness-sidecar-release-gate.yaml' @@ -78,6 +79,7 @@ on: - 'veadk/extensions/harness/**' - 'veadk/integrations/agentkit/app.py' - 'pyproject.toml' + - 'uv.lock' permissions: contents: read @@ -93,13 +95,18 @@ jobs: - uses: actions/setup-python@v5 with: python-version: '3.12' - cache: pip - cache-dependency-path: | + + - uses: astral-sh/setup-uv@v6 + with: + enable-cache: true + cache-dependency-glob: | pyproject.toml uv.lock - name: Install Python test dependencies - run: python -m pip install -e '.[dev]' + run: | + uv sync --frozen --extra dev + echo "$GITHUB_WORKSPACE/.venv/bin" >> "$GITHUB_PATH" - name: Run Python Sidecar checks in parallel shell: bash diff --git a/tests/test_harness_sidecar_release_gate.py b/tests/test_harness_sidecar_release_gate.py index 5a6afaeb4..cc31bb127 100644 --- a/tests/test_harness_sidecar_release_gate.py +++ b/tests/test_harness_sidecar_release_gate.py @@ -14,8 +14,13 @@ from __future__ import annotations +import json +import os from pathlib import Path +import subprocess +import sys +import pytest import yaml @@ -32,6 +37,112 @@ def _run_script(job: dict[str, object]) -> str: ) +def test_sidecar_dependency_cache_and_triggers_follow_lockfile() -> None: + workflow = yaml.safe_load(WORKFLOW.read_text(encoding="utf-8")) + # PyYAML's YAML 1.1 parser treats the Actions `on` key as True. + triggers = workflow[True] + for event in ("push", "pull_request"): + assert {"pyproject.toml", "uv.lock"} <= set(triggers[event]["paths"]) + steps = workflow["jobs"]["backend-gate"]["steps"] + uv_steps = [step for step in steps if "astral-sh/setup-uv@" in step.get("uses", "")] + assert len(uv_steps) == 1 + settings = uv_steps[0]["with"] + assert settings["enable-cache"] is True + assert {"pyproject.toml", "uv.lock"} <= set( + settings["cache-dependency-glob"].split() + ) + + +@pytest.mark.parametrize("failed_module", ["", "pytest", "coverage"]) +def test_sidecar_checks_use_frozen_environment_and_propagate_failure( + tmp_path: Path, failed_module: str +) -> None: + steps = yaml.safe_load(WORKFLOW.read_text(encoding="utf-8"))["jobs"][ + "backend-gate" + ]["steps"] + install = next( + step["run"] + for step in steps + if step.get("name") == "Install Python test dependencies" + ) + checks = next( + step["run"] + for step in steps + if step.get("name") == "Run Python Sidecar checks in parallel" + ) + bin_dir = tmp_path / "bin" + bin_dir.mkdir() + calls = tmp_path / "calls.jsonl" + github_path = tmp_path / "github-path" + github_path.touch() + # Simulate a cold runner: only the installer can create the test interpreter. + # Never resolve dependencies or launch actual tests from this shell contract. + uv = bin_dir / "uv" + uv.write_text( + f"#!{sys.executable}\n" + "import pathlib, sys\n" + "args = sys.argv[1:]\n" + "assert args[0] == 'sync' and '--frozen' in args\n" + "assert args[args.index('--extra') + 1] == 'dev'\n" + "target = pathlib.Path('.venv/bin/python')\n" + "target.parent.mkdir(parents=True)\n" + "target.write_bytes(pathlib.Path('test-python').read_bytes())\n" + "target.chmod(0o755)\n", + encoding="utf-8", + ) + uv.chmod(0o755) + system_python = bin_dir / "python" + system_python.write_text("#!/bin/sh\nexit 97\n", encoding="utf-8") + system_python.chmod(0o755) + (tmp_path / "test-python").write_text( + f"#!{sys.executable}\n" + "import json, os, sys\n" + "assert sys.argv[1:3] in (['-m', 'pytest'], ['-m', 'coverage'])\n" + "with open(os.environ['SIDECAR_TEST_CALLS'], 'a') as stream:\n" + " stream.write(json.dumps(sys.argv[1:]) + '\\n')\n" + "sys.exit(1 if sys.argv[2] == os.environ['SIDECAR_FAILED_MODULE'] else 0)\n", + encoding="utf-8", + ) + env = { + **os.environ, + "PATH": f"{bin_dir}{os.pathsep}{os.defpath}", + "GITHUB_PATH": str(github_path), + "GITHUB_WORKSPACE": str(tmp_path), + "RUNNER_TEMP": str(tmp_path), + "SIDECAR_TEST_CALLS": str(calls), + "SIDECAR_FAILED_MODULE": failed_module, + } + result = subprocess.run( + ["bash", "-e", "-o", "pipefail", "-c", install], + cwd=tmp_path, + env=env, + capture_output=True, + text=True, + check=False, + ) + assert result.returncode == 0, result.stderr + added_paths = github_path.read_text(encoding="utf-8").splitlines() + env["PATH"] = os.pathsep.join([*added_paths, env["PATH"]]) + result = subprocess.run( + ["bash", "-e", "-o", "pipefail", "-c", checks], + cwd=tmp_path, + env=env, + capture_output=True, + text=True, + check=False, + ) + assert result.returncode == (1 if failed_module else 0), ( + result.stdout + result.stderr + ) + commands = [json.loads(line) for line in calls.read_text().splitlines()] + assert sum(command[:2] == ["-m", "pytest"] for command in commands) == 4 + if failed_module != "pytest": + coverage = next(command for command in commands if command[1] == "coverage") + assert "--fail-under=91" in coverage + for group in ("coverage", "lifecycle", "credentials", "release"): + assert f"::group::Python Sidecar {group}" in result.stdout + + def test_sidecar_release_gate_runs_backend_and_frontend_in_parallel() -> None: workflow = yaml.safe_load(WORKFLOW.read_text(encoding="utf-8")) jobs = workflow["jobs"]