From 0bc4b779c256c09d426ce9300eea0cd5729b0654 Mon Sep 17 00:00:00 2001 From: "yutian.taoyt" Date: Sun, 1 Feb 2026 16:44:22 +0800 Subject: [PATCH 1/8] feat: automate sandbox version updates in documentation - Introduced 'sandboxes/code-interpreter/VERSION' as the single source of truth. - Added 'scripts/manage_sandbox_version.py' to automate version updates in docs/examples while preserving 'latest' in tests. - Updated '.github/workflows/manual-docker-publish.yml' to auto-trigger doc updates on release. - Added '.github/workflows/verify-sandbox-version.yml' as a CI gatekeeper. - Aligned existing documentation to v1.0.1. --- .github/workflows/manual-docker-publish.yml | 50 ++++++ .github/workflows/verify-sandbox-version.yml | 26 +++ components/egress/VERSION_TAG | 1 + components/execd/VERSION_TAG | 1 + components/ingress/VERSION_TAG | 1 + sandboxes/code-interpreter/README.md | 6 +- sandboxes/code-interpreter/README_zh.md | 8 +- sandboxes/code-interpreter/VERSION_TAG | 1 + scripts/manage_sandbox_version.py | 162 +++++++++++++++++++ 9 files changed, 249 insertions(+), 7 deletions(-) create mode 100644 .github/workflows/verify-sandbox-version.yml create mode 100644 components/egress/VERSION_TAG create mode 100644 components/execd/VERSION_TAG create mode 100644 components/ingress/VERSION_TAG create mode 100644 sandboxes/code-interpreter/VERSION_TAG create mode 100755 scripts/manage_sandbox_version.py diff --git a/.github/workflows/manual-docker-publish.yml b/.github/workflows/manual-docker-publish.yml index 5a1609f3..0be48df9 100644 --- a/.github/workflows/manual-docker-publish.yml +++ b/.github/workflows/manual-docker-publish.yml @@ -27,6 +27,8 @@ on: jobs: publish: runs-on: ubuntu-latest + permissions: + contents: write steps: - name: Checkout code uses: actions/checkout@v4 @@ -92,3 +94,51 @@ jobs: export TAG=$IMAGE_TAG chmod +x build.sh ./build.sh + + - name: Update Component Version in Docs + if: steps.parse_tag.outputs.image_tag != 'latest' + run: | + COMPONENT="${{ steps.parse_tag.outputs.component }}" + IMAGE_TAG="${{ steps.parse_tag.outputs.image_tag }}" + + echo "Updating documentation for component: $COMPONENT to version: $IMAGE_TAG" + + # Configure git + git config --global user.name "github-actions[bot]" + git config --global user.email "github-actions[bot]@users.noreply.github.com" + + # We need to switch to main branch to push changes + git fetch origin main + git checkout main + git pull origin main + + # Determine version file path + if [[ "$COMPONENT" == "code-interpreter" ]]; then + VERSION_FILE="sandboxes/code-interpreter/VERSION_TAG" + elif [[ "$COMPONENT" == "execd" ]]; then + VERSION_FILE="components/execd/VERSION_TAG" + elif [[ "$COMPONENT" == "ingress" ]]; then + VERSION_FILE="components/ingress/VERSION_TAG" + elif [[ "$COMPONENT" == "egress" ]]; then + VERSION_FILE="components/egress/VERSION_TAG" + else + echo "Unknown component for version update: $COMPONENT" + exit 0 + fi + + # Update the version file + echo "$IMAGE_TAG" > "$VERSION_FILE" + + # Run the update script for the specific component + chmod +x scripts/manage_sandbox_version.py + python3 scripts/manage_sandbox_version.py update --component "$COMPONENT" + + # Check if there are changes + if [[ -n $(git status --porcelain) ]]; then + git add . + git commit -m "chore: update $COMPONENT version to $IMAGE_TAG" + git push origin main + echo "Successfully updated documentation version for $COMPONENT." + else + echo "No changes detected." + fi diff --git a/.github/workflows/verify-sandbox-version.yml b/.github/workflows/verify-sandbox-version.yml new file mode 100644 index 00000000..9d47fa4a --- /dev/null +++ b/.github/workflows/verify-sandbox-version.yml @@ -0,0 +1,26 @@ +name: Verify Sandbox Versions + +on: + pull_request: + branches: [ main ] + push: + branches: [ main ] + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + verify-versions: + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.10' + + - name: Run version verification + run: python3 scripts/manage_sandbox_version.py verify diff --git a/components/egress/VERSION_TAG b/components/egress/VERSION_TAG new file mode 100644 index 00000000..b9bc2fdc --- /dev/null +++ b/components/egress/VERSION_TAG @@ -0,0 +1 @@ +latest \ No newline at end of file diff --git a/components/execd/VERSION_TAG b/components/execd/VERSION_TAG new file mode 100644 index 00000000..c1757a8d --- /dev/null +++ b/components/execd/VERSION_TAG @@ -0,0 +1 @@ +v1.0.5 \ No newline at end of file diff --git a/components/ingress/VERSION_TAG b/components/ingress/VERSION_TAG new file mode 100644 index 00000000..b9bc2fdc --- /dev/null +++ b/components/ingress/VERSION_TAG @@ -0,0 +1 @@ +latest \ No newline at end of file diff --git a/sandboxes/code-interpreter/README.md b/sandboxes/code-interpreter/README.md index 2b7943a2..b5497847 100644 --- a/sandboxes/code-interpreter/README.md +++ b/sandboxes/code-interpreter/README.md @@ -55,7 +55,7 @@ docker run -it --rm \ -e JAVA_VERSION=17 \ -e NODE_VERSION=20 \ -e GO_VERSION=1.24 \ - opensandbox/code-interpreter:latest + opensandbox/code-interpreter:v1.0.1 ``` ## Version Switching @@ -167,7 +167,7 @@ Mount a local directory to persist your work: ```bash docker run -it --rm \ -v $(pwd)/workspace:/workspace \ - opensandbox/code-interpreter:latest + opensandbox/code-interpreter:v1.0.1 ``` ### Custom Configuration @@ -177,7 +177,7 @@ Override Jupyter configuration: ```bash docker run -it --rm \ -v $(pwd)/jupyter_config.py:/root/.jupyter/jupyter_notebook_config.py \ - opensandbox/code-interpreter:latest + opensandbox/code-interpreter:v1.0.1 ``` ### Install Additional Packages diff --git a/sandboxes/code-interpreter/README_zh.md b/sandboxes/code-interpreter/README_zh.md index e68b2584..e07a5297 100644 --- a/sandboxes/code-interpreter/README_zh.md +++ b/sandboxes/code-interpreter/README_zh.md @@ -39,7 +39,7 @@ cd sandboxes/code-interpreter # 构建本地镜像 docker build -t sandbox-registry.cn-zhangjiakou.cr.aliyuncs.com/opensandbox/code-interpreter:latest . -# 多架构构建(需要 Docker Buildx) +# 构建多架构镜像(需要 Docker Buildx) docker buildx build --platform linux/amd64,linux/arm64 \ -t sandbox-registry.cn-zhangjiakou.cr.aliyuncs.com/opensandbox/code-interpreter:latest . ``` @@ -54,7 +54,7 @@ docker run -it --rm \ -e JAVA_VERSION=17 \ -e NODE_VERSION=20 \ -e GO_VERSION=1.24 \ - sandbox-registry.cn-zhangjiakou.cr.aliyuncs.com/opensandbox/code-interpreter:latest + sandbox-registry.cn-zhangjiakou.cr.aliyuncs.com/opensandbox/code-interpreter:v1.0.1 ``` ## 如何切换版本 @@ -165,7 +165,7 @@ source /opt/opensandbox/code-interpreter-env.sh go ```bash docker run -it --rm \ -v $(pwd)/workspace:/workspace \ - sandbox-registry.cn-zhangjiakou.cr.aliyuncs.com/opensandbox/code-interpreter:latest + sandbox-registry.cn-zhangjiakou.cr.aliyuncs.com/opensandbox/code-interpreter:v1.0.1 ``` ### 自定义配置 @@ -175,7 +175,7 @@ docker run -it --rm \ ```bash docker run -it --rm \ -v $(pwd)/jupyter_config.py:/root/.jupyter/jupyter_notebook_config.py \ - sandbox-registry.cn-zhangjiakou.cr.aliyuncs.com/opensandbox/code-interpreter:latest + sandbox-registry.cn-zhangjiakou.cr.aliyuncs.com/opensandbox/code-interpreter:v1.0.1 ``` ### 安装额外的包 diff --git a/sandboxes/code-interpreter/VERSION_TAG b/sandboxes/code-interpreter/VERSION_TAG new file mode 100644 index 00000000..6a2b0ac4 --- /dev/null +++ b/sandboxes/code-interpreter/VERSION_TAG @@ -0,0 +1 @@ +v1.0.1 \ No newline at end of file diff --git a/scripts/manage_sandbox_version.py b/scripts/manage_sandbox_version.py new file mode 100755 index 00000000..16f41d97 --- /dev/null +++ b/scripts/manage_sandbox_version.py @@ -0,0 +1,162 @@ +#!/usr/bin/env python3 + +# Copyright 2026 Alibaba Group Holding Ltd. +# +# 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. + +import os +import re +import sys +import argparse +from pathlib import Path + +# Configuration +PROJECT_ROOT = Path(__file__).resolve().parent.parent + +# Component Configuration +COMPONENTS = { + "code-interpreter": { + "image": "opensandbox/code-interpreter", + "version_file": PROJECT_ROOT / "sandboxes" / "code-interpreter" / "VERSION_TAG" + }, + "execd": { + "image": "opensandbox/execd", + "version_file": PROJECT_ROOT / "components" / "execd" / "VERSION_TAG" + }, + "ingress": { + "image": "opensandbox/ingress", + "version_file": PROJECT_ROOT / "components" / "ingress" / "VERSION_TAG" + }, + "egress": { + "image": "opensandbox/egress", + "version_file": PROJECT_ROOT / "components" / "egress" / "VERSION_TAG" + } +} + +# Files/Directories to ignore during scan +IGNORE_DIRS = { ".git", ".idea", ".vscode", "__pycache__", "node_modules", "dist", "build", ".gemini"} +# Extensions to scan +INCLUDE_EXTS = { ".md", ".py", ".java", ".ts", ".js", ".kt", ".sh", ".yaml", ".yml", ".toml", ".properties"} + +def get_pattern(image_name): + # Regex to match image usage: [registry/][user/]repo:tag + # Group 1: Optional registry/user prefix + # Group 2: The specific version tag + # We ignore ${TAG}, $TAG, :latest, :local, :dev, :test to avoid breaking build scripts and tests + return re.compile(r'([a-zA-Z0-9.-]+(?::\d+)?/)?' + re.escape(image_name) + r":(?![\$\{]|latest\b|local\b|dev\b|test\b)([a-zA-Z0-9._-]+)") + +def get_current_version(component_name): + version_file = COMPONENTS[component_name]["version_file"] + if not version_file.exists(): + print(f"Error: Version file not found at {version_file} for component {component_name}") + sys.exit(1) + return version_file.read_text().strip() + +def should_process_file(path: Path): + if path.name.startswith("."): + return False + if path.suffix not in INCLUDE_EXTS: + return False + # Specific exclusions can be added here + if path.resolve() == Path(__file__).resolve(): + return False + return True + +def scan_files(root: Path): + for root_dir, dirs, files in os.walk(root): + # Modify dirs in-place to skip ignored directories + dirs[:] = [d for d in dirs if d not in IGNORE_DIRS] + + for file in files: + file_path = Path(root_dir) / file + if should_process_file(file_path): + yield file_path + +def verify_component(component_name): + target_version = get_current_version(component_name) + image_name = COMPONENTS[component_name]["image"] + pattern = get_pattern(image_name) + + print(f"Verifying {component_name} ({image_name}:{target_version})...") + errors = [] + + for file_path in scan_files(PROJECT_ROOT): + try: + content = file_path.read_text(encoding="utf-8", errors="ignore") + for match in pattern.finditer(content): + version = match.group(2) + if version != target_version: + errors.append(f"{file_path.relative_to(PROJECT_ROOT)}: Found {version}, expected {target_version}") + except Exception as e: + print(f"Warning: Could not read {file_path}: {e}") + + return errors + +def update_component(component_name): + target_version = get_current_version(component_name) + image_name = COMPONENTS[component_name]["image"] + pattern = get_pattern(image_name) + + print(f"Updating {component_name} to {target_version}...") + count = 0 + + for file_path in scan_files(PROJECT_ROOT): + try: + content = file_path.read_text(encoding="utf-8", errors="ignore") + + def replace_func(match): + prefix = match.group(1) or "" + return f"{prefix}{image_name}:{target_version}" + + new_content, n = pattern.subn(replace_func, content) + + if n > 0: + if new_content != content: + file_path.write_text(new_content, encoding="utf-8") + print(f"Updated {n} occurrence(s) in {file_path.relative_to(PROJECT_ROOT)}") + count += 1 + except Exception as e: + print(f"Warning: Could not update {file_path}: {e}") + + print(f"✨ Updated {count} files for {component_name}.") + +def main(): + parser = argparse.ArgumentParser(description="Manage OpenSandbox component versions in documentation.") + parser.add_argument("action", choices=["verify", "update"], help="Action to perform") + parser.add_argument("--component", choices=COMPONENTS.keys(), help="Specific component to process. If omitted, all are processed.") + + args = parser.parse_args() + + components_to_process = [args.component] if args.component else COMPONENTS.keys() + + if args.action == "verify": + all_errors = [] + for comp in components_to_process: + errors = verify_component(comp) + all_errors.extend(errors) + + if all_errors: + print("\n❌ Version mismatches found:") + for error in all_errors: + print(f" - {error}") + print("\nPlease run: python3 scripts/manage_sandbox_version.py update") + sys.exit(1) + else: + print("\n✅ All versions match.") + + elif args.action == "update": + for comp in components_to_process: + update_component(comp) + +if __name__ == "__main__": + main() From f47566fa5f118447564a4a027f4af046629d6098 Mon Sep 17 00:00:00 2001 From: "yutian.taoyt" Date: Sun, 1 Feb 2026 19:23:40 +0800 Subject: [PATCH 2/8] fix: update regex to support multi-segment registry prefixes in version manager --- scripts/manage_sandbox_version.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/manage_sandbox_version.py b/scripts/manage_sandbox_version.py index 16f41d97..fdb6cbad 100755 --- a/scripts/manage_sandbox_version.py +++ b/scripts/manage_sandbox_version.py @@ -53,7 +53,7 @@ def get_pattern(image_name): # Group 1: Optional registry/user prefix # Group 2: The specific version tag # We ignore ${TAG}, $TAG, :latest, :local, :dev, :test to avoid breaking build scripts and tests - return re.compile(r'([a-zA-Z0-9.-]+(?::\d+)?/)?' + re.escape(image_name) + r":(?![\$\{]|latest\b|local\b|dev\b|test\b)([a-zA-Z0-9._-]+)") + return re.compile(r'((?:[a-zA-Z0-9._-]+(?::\d+)?/)+)?' + re.escape(image_name) + r":(?![\$\{]|latest\b|local\b|dev\b|test\b)([a-zA-Z0-9._-]+)") def get_current_version(component_name): version_file = COMPONENTS[component_name]["version_file"] From 64a9def9b0c495f836d3dfb9ce4f33ce1d49b2ec Mon Sep 17 00:00:00 2001 From: "yutian.taoyt" Date: Sun, 1 Feb 2026 19:27:08 +0800 Subject: [PATCH 3/8] docs: use latest tag in developer-oriented sandbox READMEs --- sandboxes/code-interpreter/README.md | 6 +++--- sandboxes/code-interpreter/README_zh.md | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/sandboxes/code-interpreter/README.md b/sandboxes/code-interpreter/README.md index b5497847..2b7943a2 100644 --- a/sandboxes/code-interpreter/README.md +++ b/sandboxes/code-interpreter/README.md @@ -55,7 +55,7 @@ docker run -it --rm \ -e JAVA_VERSION=17 \ -e NODE_VERSION=20 \ -e GO_VERSION=1.24 \ - opensandbox/code-interpreter:v1.0.1 + opensandbox/code-interpreter:latest ``` ## Version Switching @@ -167,7 +167,7 @@ Mount a local directory to persist your work: ```bash docker run -it --rm \ -v $(pwd)/workspace:/workspace \ - opensandbox/code-interpreter:v1.0.1 + opensandbox/code-interpreter:latest ``` ### Custom Configuration @@ -177,7 +177,7 @@ Override Jupyter configuration: ```bash docker run -it --rm \ -v $(pwd)/jupyter_config.py:/root/.jupyter/jupyter_notebook_config.py \ - opensandbox/code-interpreter:v1.0.1 + opensandbox/code-interpreter:latest ``` ### Install Additional Packages diff --git a/sandboxes/code-interpreter/README_zh.md b/sandboxes/code-interpreter/README_zh.md index e07a5297..ba1d8578 100644 --- a/sandboxes/code-interpreter/README_zh.md +++ b/sandboxes/code-interpreter/README_zh.md @@ -54,7 +54,7 @@ docker run -it --rm \ -e JAVA_VERSION=17 \ -e NODE_VERSION=20 \ -e GO_VERSION=1.24 \ - sandbox-registry.cn-zhangjiakou.cr.aliyuncs.com/opensandbox/code-interpreter:v1.0.1 + sandbox-registry.cn-zhangjiakou.cr.aliyuncs.com/opensandbox/code-interpreter:latest ``` ## 如何切换版本 @@ -165,7 +165,7 @@ source /opt/opensandbox/code-interpreter-env.sh go ```bash docker run -it --rm \ -v $(pwd)/workspace:/workspace \ - sandbox-registry.cn-zhangjiakou.cr.aliyuncs.com/opensandbox/code-interpreter:v1.0.1 + sandbox-registry.cn-zhangjiakou.cr.aliyuncs.com/opensandbox/code-interpreter:latest ``` ### 自定义配置 @@ -175,7 +175,7 @@ docker run -it --rm \ ```bash docker run -it --rm \ -v $(pwd)/jupyter_config.py:/root/.jupyter/jupyter_notebook_config.py \ - sandbox-registry.cn-zhangjiakou.cr.aliyuncs.com/opensandbox/code-interpreter:v1.0.1 + sandbox-registry.cn-zhangjiakou.cr.aliyuncs.com/opensandbox/code-interpreter:latest ``` ### 安装额外的包 From 6ab4b97dedf248d82c559b0ffd741bcf864f8c89 Mon Sep 17 00:00:00 2001 From: "yutian.taoyt" Date: Sun, 1 Feb 2026 19:29:08 +0800 Subject: [PATCH 4/8] security: use PR for version updates instead of pushing to main --- .github/workflows/manual-docker-publish.yml | 21 +++++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/.github/workflows/manual-docker-publish.yml b/.github/workflows/manual-docker-publish.yml index 0be48df9..3b8428e2 100644 --- a/.github/workflows/manual-docker-publish.yml +++ b/.github/workflows/manual-docker-publish.yml @@ -29,6 +29,7 @@ jobs: runs-on: ubuntu-latest permissions: contents: write + pull-requests: write steps: - name: Checkout code uses: actions/checkout@v4 @@ -111,6 +112,10 @@ jobs: git fetch origin main git checkout main git pull origin main + + # Create a new branch for the update + BRANCH_NAME="chore/update-${COMPONENT}-${IMAGE_TAG}" + git checkout -b "$BRANCH_NAME" # Determine version file path if [[ "$COMPONENT" == "code-interpreter" ]]; then @@ -137,8 +142,20 @@ jobs: if [[ -n $(git status --porcelain) ]]; then git add . git commit -m "chore: update $COMPONENT version to $IMAGE_TAG" - git push origin main - echo "Successfully updated documentation version for $COMPONENT." + + # Push the branch + git push origin "$BRANCH_NAME" + + # Create a Pull Request + gh pr create \ + --title "chore: update $COMPONENT version to $IMAGE_TAG" \ + --body "Automated version update for $COMPONENT to $IMAGE_TAG triggered by workflow." \ + --base main \ + --head "$BRANCH_NAME" + + echo "Successfully created PR for $COMPONENT version update." else echo "No changes detected." fi + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} From aa24cdf48de610f7a7439df6da9214625a1e4c45 Mon Sep 17 00:00:00 2001 From: "yutian.taoyt" Date: Sun, 1 Feb 2026 19:32:13 +0800 Subject: [PATCH 5/8] test: add unit tests for manage_sandbox_version.py --- scripts/test_manage_sandbox_version.py | 171 +++++++++++++++++++++++++ 1 file changed, 171 insertions(+) create mode 100644 scripts/test_manage_sandbox_version.py diff --git a/scripts/test_manage_sandbox_version.py b/scripts/test_manage_sandbox_version.py new file mode 100644 index 00000000..949f0835 --- /dev/null +++ b/scripts/test_manage_sandbox_version.py @@ -0,0 +1,171 @@ + +import unittest +import re +from unittest.mock import patch, MagicMock, mock_open +from pathlib import Path +import sys + +# Add scripts directory to sys.path to allow importing manage_sandbox_version +sys.path.append(str(Path(__file__).parent)) + +import manage_sandbox_version + +class TestManageSandboxVersion(unittest.TestCase): + + def test_get_pattern_basic(self): + pattern = manage_sandbox_version.get_pattern("opensandbox/execd") + + # Test matches + self.assertTrue(pattern.search("image: opensandbox/execd:v1.0.0")) + self.assertTrue(pattern.search('"opensandbox/execd:v1.2.3"')) + + # Test group capturing + match = pattern.search("opensandbox/execd:v1.0.0") + self.assertEqual(match.group(2), "v1.0.0") + self.assertIsNone(match.group(1)) + + def test_get_pattern_registry_prefix(self): + pattern = manage_sandbox_version.get_pattern("opensandbox/execd") + + # Simple registry + text = "registry.example.com/opensandbox/execd:v1.0.0" + match = pattern.search(text) + self.assertTrue(match) + self.assertEqual(match.group(1), "registry.example.com/") + self.assertEqual(match.group(2), "v1.0.0") + + # Complex registry with port and multi-level path + text = "sandbox-registry.cn-zhangjiakou.cr.aliyuncs.com/opensandbox/execd:v1.0.5" + match = pattern.search(text) + self.assertTrue(match) + self.assertEqual(match.group(1), "sandbox-registry.cn-zhangjiakou.cr.aliyuncs.com/") + self.assertEqual(match.group(2), "v1.0.5") + + def test_get_pattern_ignores(self): + pattern = manage_sandbox_version.get_pattern("opensandbox/execd") + + # Should ignore these tags + self.assertFalse(pattern.search("opensandbox/execd:latest")) + self.assertFalse(pattern.search("opensandbox/execd:local")) + self.assertFalse(pattern.search("opensandbox/execd:dev")) + self.assertFalse(pattern.search("opensandbox/execd:test")) + self.assertFalse(pattern.search("opensandbox/execd:${TAG}")) + self.assertFalse(pattern.search("opensandbox/execd:$TAG")) + + def test_should_process_file(self): + # Valid files + self.assertTrue(manage_sandbox_version.should_process_file(Path("README.md"))) + self.assertTrue(manage_sandbox_version.should_process_file(Path("script.py"))) + self.assertTrue(manage_sandbox_version.should_process_file(Path("config.yaml"))) + + # Invalid files + self.assertFalse(manage_sandbox_version.should_process_file(Path(".hidden"))) + self.assertFalse(manage_sandbox_version.should_process_file(Path("image.png"))) + self.assertFalse(manage_sandbox_version.should_process_file(Path("text.txt"))) + + # Should ignore the script itself + script_path = Path(manage_sandbox_version.__file__) + self.assertFalse(manage_sandbox_version.should_process_file(script_path)) + + @patch('manage_sandbox_version.PROJECT_ROOT', Path("/mock/root")) + @patch('manage_sandbox_version.COMPONENTS') + def test_get_current_version(self, mock_components): + mock_version_file = MagicMock() + mock_version_file.exists.return_value = True + mock_version_file.read_text.return_value = "v1.2.3\n" + + mock_components.__getitem__.return_value = { + "version_file": mock_version_file + } + + version = manage_sandbox_version.get_current_version("test-component") + self.assertEqual(version, "v1.2.3") + + @patch('manage_sandbox_version.scan_files') + @patch('manage_sandbox_version.get_current_version') + @patch('manage_sandbox_version.COMPONENTS') + def test_verify_component_mismatch(self, mock_components, mock_get_version, mock_scan_files): + # Setup + mock_get_version.return_value = "v2.0.0" + mock_components.__getitem__.return_value = {"image": "test/image"} + + # Mock file content with mismatching version + mock_file = MagicMock() + mock_file.read_text.return_value = "image: test/image:v1.0.0" + mock_file.relative_to.return_value = Path("test/file.md") + mock_scan_files.return_value = [mock_file] + + # Run + errors = manage_sandbox_version.verify_component("test-component") + + # Assert + self.assertEqual(len(errors), 1) + self.assertIn("Found v1.0.0, expected v2.0.0", errors[0]) + + @patch('manage_sandbox_version.scan_files') + @patch('manage_sandbox_version.get_current_version') + @patch('manage_sandbox_version.COMPONENTS') + def test_verify_component_match(self, mock_components, mock_get_version, mock_scan_files): + # Setup + mock_get_version.return_value = "v2.0.0" + mock_components.__getitem__.return_value = {"image": "test/image"} + + # Mock file content with matching version + mock_file = MagicMock() + mock_file.read_text.return_value = "image: test/image:v2.0.0" + mock_scan_files.return_value = [mock_file] + + # Run + errors = manage_sandbox_version.verify_component("test-component") + + # Assert + self.assertEqual(len(errors), 0) + + @patch('manage_sandbox_version.scan_files') + @patch('manage_sandbox_version.get_current_version') + @patch('manage_sandbox_version.COMPONENTS') + def test_update_component(self, mock_components, mock_get_version, mock_scan_files): + # Setup + mock_get_version.return_value = "v2.0.0" + mock_components.__getitem__.return_value = {"image": "test/image"} + + # Mock file with old version + mock_file = MagicMock() + original_content = "some config\nimage: test/image:v1.0.0\nend" + mock_file.read_text.return_value = original_content + mock_file.relative_to.return_value = Path("test/config.yaml") + mock_scan_files.return_value = [mock_file] + + # Run + with patch('builtins.print'): # suppress print output + manage_sandbox_version.update_component("test-component") + + # Assert + expected_content = "some config\nimage: test/image:v2.0.0\nend" + mock_file.write_text.assert_called_once_with(expected_content, encoding="utf-8") + + @patch('manage_sandbox_version.scan_files') + @patch('manage_sandbox_version.get_current_version') + @patch('manage_sandbox_version.COMPONENTS') + def test_update_component_preserves_prefix(self, mock_components, mock_get_version, mock_scan_files): + # Setup + mock_get_version.return_value = "v2.0.0" + mock_components.__getitem__.return_value = {"image": "test/image"} + + # Mock file with registry prefix + mock_file = MagicMock() + original_content = "image: my.registry.com/test/image:v1.0.0" + mock_file.read_text.return_value = original_content + mock_file.relative_to.return_value = Path("test/config.yaml") + mock_scan_files.return_value = [mock_file] + + # Run + with patch('builtins.print'): + manage_sandbox_version.update_component("test-component") + + # Assert + expected_content = "image: my.registry.com/test/image:v2.0.0" + mock_file.write_text.assert_called_once_with(expected_content, encoding="utf-8") + +if __name__ == '__main__': + unittest.main() From 9aa7b9ab4d8efd4c8dd0203bd32c8fd2f495260e Mon Sep 17 00:00:00 2001 From: "yutian.taoyt" Date: Sun, 1 Feb 2026 19:40:59 +0800 Subject: [PATCH 6/8] ci: run manage_sandbox_version unit tests in CI --- .github/workflows/verify-sandbox-version.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/verify-sandbox-version.yml b/.github/workflows/verify-sandbox-version.yml index 9d47fa4a..477d4278 100644 --- a/.github/workflows/verify-sandbox-version.yml +++ b/.github/workflows/verify-sandbox-version.yml @@ -22,5 +22,8 @@ jobs: with: python-version: '3.10' + - name: Run script unit tests + run: python3 scripts/test_manage_sandbox_version.py + - name: Run version verification run: python3 scripts/manage_sandbox_version.py verify From 0b07b6a8300c00c8d34e2ccf195a0a84dbbdc0f5 Mon Sep 17 00:00:00 2001 From: "yutian.taoyt" Date: Sun, 1 Feb 2026 19:44:44 +0800 Subject: [PATCH 7/8] fix: exclude test script from version scanning --- scripts/manage_sandbox_version.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/scripts/manage_sandbox_version.py b/scripts/manage_sandbox_version.py index fdb6cbad..2e903df3 100755 --- a/scripts/manage_sandbox_version.py +++ b/scripts/manage_sandbox_version.py @@ -70,6 +70,8 @@ def should_process_file(path: Path): # Specific exclusions can be added here if path.resolve() == Path(__file__).resolve(): return False + if path.name == "test_manage_sandbox_version.py": + return False return True def scan_files(root: Path): From 26f55a9e2925aeb8aaf4d14011d5709de241b3de Mon Sep 17 00:00:00 2001 From: "yutian.taoyt" Date: Sun, 1 Feb 2026 19:46:04 +0800 Subject: [PATCH 8/8] chore: add license header to test script --- scripts/test_manage_sandbox_version.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/scripts/test_manage_sandbox_version.py b/scripts/test_manage_sandbox_version.py index 949f0835..f1443a8c 100644 --- a/scripts/test_manage_sandbox_version.py +++ b/scripts/test_manage_sandbox_version.py @@ -1,3 +1,18 @@ +#!/usr/bin/env python3 + +# Copyright 2026 Alibaba Group Holding Ltd. +# +# 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. import unittest import re