diff --git a/.github/workflows/manual-docker-publish.yml b/.github/workflows/manual-docker-publish.yml index 5a1609f3..3b8428e2 100644 --- a/.github/workflows/manual-docker-publish.yml +++ b/.github/workflows/manual-docker-publish.yml @@ -27,6 +27,9 @@ on: jobs: publish: runs-on: ubuntu-latest + permissions: + contents: write + pull-requests: write steps: - name: Checkout code uses: actions/checkout@v4 @@ -92,3 +95,67 @@ 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 + + # 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 + 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" + + # 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 }} diff --git a/.github/workflows/verify-sandbox-version.yml b/.github/workflows/verify-sandbox-version.yml new file mode 100644 index 00000000..477d4278 --- /dev/null +++ b/.github/workflows/verify-sandbox-version.yml @@ -0,0 +1,29 @@ +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 script unit tests + run: python3 scripts/test_manage_sandbox_version.py + + - 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_zh.md b/sandboxes/code-interpreter/README_zh.md index e68b2584..ba1d8578 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 . ``` 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..2e903df3 --- /dev/null +++ b/scripts/manage_sandbox_version.py @@ -0,0 +1,164 @@ +#!/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 + if path.name == "test_manage_sandbox_version.py": + 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() diff --git a/scripts/test_manage_sandbox_version.py b/scripts/test_manage_sandbox_version.py new file mode 100644 index 00000000..f1443a8c --- /dev/null +++ b/scripts/test_manage_sandbox_version.py @@ -0,0 +1,186 @@ +#!/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 +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()