chore(holopatcher)(deps): update charset-normalizer requirement from >=2.0,<3.4 to >=2.0,<3.5 in /Tools/HoloPatcher #22
Workflow file for this run
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: PR Build Validation | |
| on: | |
| pull_request: | |
| types: [opened, synchronize, reopened] | |
| paths: | |
| # Trigger on tool changes (any tool under Tools/) | |
| - 'Tools/*/**' | |
| # Trigger on library changes (affects all tools) | |
| - 'Libraries/PyKotor/**' | |
| # Trigger on compile script changes | |
| - 'compile/**' | |
| workflow_dispatch: | |
| inputs: | |
| tool: | |
| description: 'Tool to build (toolset, holopatcher, kotordiff, etc.)' | |
| required: false | |
| default: 'all-affected' | |
| full_build: | |
| description: 'Run full build instead of dry-run' | |
| required: false | |
| default: false | |
| type: boolean | |
| concurrency: | |
| group: pr-build-${{ github.ref }} | |
| cancel-in-progress: true | |
| permissions: | |
| contents: read | |
| pull-requests: write | |
| jobs: | |
| # Detect which tools were affected by the PR | |
| detect-changes: | |
| name: Detect Changed Tools | |
| runs-on: ubuntu-latest | |
| outputs: | |
| tools_matrix: ${{ steps.changes.outputs.tools_matrix }} | |
| libraries: ${{ steps.changes.outputs.libraries }} | |
| compile: ${{ steps.changes.outputs.compile }} | |
| any_tool: ${{ steps.changes.outputs.any_tool }} | |
| steps: | |
| - uses: actions/checkout@v4 | |
| with: | |
| fetch-depth: 0 | |
| - name: Setup Python | |
| uses: actions/setup-python@v5 | |
| with: | |
| python-version: '3.11' | |
| - name: Detect file changes | |
| id: changes | |
| run: | | |
| python3 << 'PYTHON_SCRIPT' | |
| import json | |
| import os | |
| import re | |
| from pathlib import Path | |
| def derive_build_name(tool_dir_name: str) -> str: | |
| """Derive the build name from tool directory name.""" | |
| name_lower = tool_dir_name.lower() | |
| # Handle known special cases for backward compatibility | |
| special_mappings = { | |
| "holocrontoolset": "toolset", | |
| "holopatcher": "holopatcher", | |
| "batchpatcher": "batchpatcher", | |
| } | |
| if name_lower in special_mappings: | |
| return special_mappings[name_lower] | |
| # Default: use the tool name in lowercase | |
| return name_lower | |
| def find_config_file(tool_dir: Path, tool_name_lower: str) -> Path | None: | |
| """Find the config file for a tool.""" | |
| src_dir = tool_dir / "src" / tool_name_lower | |
| # Check for config.py directly in src/<tool>/ | |
| config_py = src_dir / "config.py" | |
| if config_py.exists(): | |
| return config_py | |
| # Check for config/config_info.py | |
| config_info_py = src_dir / "config" / "config_info.py" | |
| if config_info_py.exists(): | |
| return config_info_py | |
| # Check for __main__.py with CURRENT_VERSION | |
| main_py = src_dir / "__main__.py" | |
| if main_py.exists(): | |
| content = main_py.read_text() | |
| if 'CURRENT_VERSION' in content: | |
| return main_py | |
| # Check for __init__.py with __version__ | |
| init_py = src_dir / "__init__.py" | |
| if init_py.exists(): | |
| content = init_py.read_text() | |
| if '__version__' in content: | |
| return init_py | |
| return None | |
| # Get changed files | |
| event_name = os.environ.get('GITHUB_EVENT_NAME', '') | |
| base_ref = os.environ.get('GITHUB_BASE_REF', 'master') | |
| if event_name == 'pull_request': | |
| import subprocess | |
| result = subprocess.run( | |
| ['git', 'diff', '--name-only', f'origin/{base_ref}...HEAD'], | |
| capture_output=True, text=True, check=True | |
| ) | |
| changed_files = result.stdout.strip().split('\n') | |
| else: | |
| import subprocess | |
| result = subprocess.run( | |
| ['git', 'diff', '--name-only', 'HEAD~1'], | |
| capture_output=True, text=True, check=True | |
| ) | |
| changed_files = result.stdout.strip().split('\n') | |
| changed_files = [f for f in changed_files if f.strip()] | |
| print("Changed files:") | |
| for f in changed_files: | |
| print(f" {f}") | |
| # Discover all tools in Tools/ | |
| tools_dir = Path('Tools') | |
| all_tools = [] | |
| changed_tools = [] | |
| if tools_dir.exists(): | |
| for tool_dir in sorted(tools_dir.iterdir()): | |
| if tool_dir.is_dir() and not tool_dir.name.startswith('.'): | |
| tool_name = tool_dir.name | |
| build_name = derive_build_name(tool_name) | |
| tool_name_lower = tool_name.lower() | |
| # Check if this tool has a compile script | |
| compile_script = Path(f'compile/compile_{build_name}.ps1') | |
| if not compile_script.exists(): | |
| # Try .sh or .bat | |
| compile_script = Path(f'compile/compile_{build_name}.sh') | |
| if not compile_script.exists(): | |
| compile_script = Path(f'compile/compile_{build_name}.bat') | |
| if not compile_script.exists(): | |
| print(f" Skipping {tool_name}: no compile script found") | |
| continue | |
| # Check if tool was changed | |
| tool_changed = any(f.startswith(f'Tools/{tool_name}/') for f in changed_files) | |
| tool_info = { | |
| 'tool_dir': tool_name, | |
| 'build_name': build_name, | |
| 'display_name': tool_name, | |
| 'changed': tool_changed, | |
| 'has_config': find_config_file(tool_dir, tool_name_lower) is not None | |
| } | |
| all_tools.append(tool_info) | |
| if tool_changed: | |
| changed_tools.append(tool_info) | |
| # Check for Libraries/ changes | |
| libraries_changed = any(f.startswith('Libraries/') for f in changed_files) | |
| # Check for compile/ changes | |
| compile_changed = any(f.startswith('compile/') for f in changed_files) | |
| # If libraries or compile changed, mark all tools as affected | |
| if libraries_changed or compile_changed: | |
| for tool in all_tools: | |
| tool['changed'] = True | |
| changed_tools = all_tools | |
| # Build tools matrix for GitHub Actions | |
| tools_matrix = [] | |
| for tool in changed_tools: | |
| tools_matrix.append({ | |
| 'tool_dir': tool['tool_dir'], | |
| 'build_name': tool['build_name'], | |
| 'display_name': tool['display_name'], | |
| 'has_config': tool['has_config'] | |
| }) | |
| any_tool = len(changed_tools) > 0 | |
| # Output results | |
| print("\nSummary:") | |
| print(f" Libraries changed: {libraries_changed}") | |
| print(f" Compile scripts changed: {compile_changed}") | |
| print(f" Tools affected: {len(changed_tools)}") | |
| for tool in changed_tools: | |
| print(f" - {tool['display_name']} (build_name: {tool['build_name']})") | |
| print(f" Any tool affected: {any_tool}") | |
| # Set outputs | |
| with open(os.environ['GITHUB_OUTPUT'], 'a') as f: | |
| f.write(f"tools_matrix={json.dumps(tools_matrix)}\n") | |
| f.write(f"libraries={'true' if libraries_changed else 'false'}\n") | |
| f.write(f"compile={'true' if compile_changed else 'false'}\n") | |
| f.write(f"any_tool={'true' if any_tool else 'false'}\n") | |
| PYTHON_SCRIPT | |
| # Validate version consistency in config files | |
| version-check: | |
| name: Version Consistency Check | |
| runs-on: ubuntu-latest | |
| needs: detect-changes | |
| if: needs.detect-changes.outputs.any_tool == 'true' | |
| steps: | |
| - uses: actions/checkout@v4 | |
| - name: Setup Python | |
| uses: actions/setup-python@v5 | |
| with: | |
| python-version: '3.11' | |
| - name: Check version files are valid | |
| run: | | |
| python3 << 'PYTHON_SCRIPT' | |
| import json | |
| import os | |
| import re | |
| from pathlib import Path | |
| def find_config_file(tool_dir: Path, tool_name_lower: str) -> Path | None: | |
| """Find the config file for a tool.""" | |
| src_dir = tool_dir / "src" / tool_name_lower | |
| # Check for config.py directly in src/<tool>/ | |
| config_py = src_dir / "config.py" | |
| if config_py.exists(): | |
| return config_py | |
| # Check for config/config_info.py | |
| config_info_py = src_dir / "config" / "config_info.py" | |
| if config_info_py.exists(): | |
| return config_info_py | |
| # Check for __main__.py with CURRENT_VERSION | |
| main_py = src_dir / "__main__.py" | |
| if main_py.exists(): | |
| content = main_py.read_text() | |
| if 'CURRENT_VERSION' in content: | |
| return main_py | |
| # Check for __init__.py with __version__ | |
| init_py = src_dir / "__init__.py" | |
| if init_py.exists(): | |
| content = init_py.read_text() | |
| if '__version__' in content: | |
| return init_py | |
| return None | |
| def check_version_file(config_path: Path, tool_name: str) -> list[str]: | |
| """Check version file and return list of errors.""" | |
| errors = [] | |
| try: | |
| content = config_path.read_text() | |
| # Check for currentVersion (JSON-style in Python dict) | |
| if '"currentVersion":' in content: | |
| match = re.search(r'"currentVersion":\s*"([^"]+)"', content) | |
| if not match: | |
| errors.append(f"{tool_name}: currentVersion not found") | |
| else: | |
| print(f"✓ {tool_name} currentVersion: {match.group(1)}") | |
| # Check for toolsetLatestVersion pattern (for toolset) | |
| if 'toolsetLatestVersion' in content: | |
| match = re.search(r'"toolsetLatestVersion":\s*"([^"]+)"', content) | |
| if not match: | |
| errors.append(f"{tool_name}: toolsetLatestVersion not found") | |
| else: | |
| print(f"✓ {tool_name} latestVersion: {match.group(1)}") | |
| if 'toolsetLatestBetaVersion' in content: | |
| match = re.search(r'"toolsetLatestBetaVersion":\s*"([^"]+)"', content) | |
| if not match: | |
| errors.append(f"{tool_name}: toolsetLatestBetaVersion not found") | |
| else: | |
| print(f"✓ {tool_name} betaVersion: {match.group(1)}") | |
| # Check for CURRENT_VERSION | |
| elif 'CURRENT_VERSION' in content: | |
| match = re.search(r'CURRENT_VERSION\s*=\s*["\']([^"\']+)["\']', content) | |
| if not match: | |
| errors.append(f"{tool_name}: CURRENT_VERSION not found") | |
| else: | |
| print(f"✓ {tool_name} CURRENT_VERSION: {match.group(1)}") | |
| # Check for __version__ | |
| elif '__version__' in content: | |
| match = re.search(r'__version__\s*=\s*["\']([^"\']+)["\']', content) | |
| if not match: | |
| errors.append(f"{tool_name}: __version__ not found") | |
| else: | |
| print(f"✓ {tool_name} __version__: {match.group(1)}") | |
| except Exception as e: | |
| errors.append(f"{tool_name}: Error reading config file: {e}") | |
| return errors | |
| # Get tools matrix from environment variable (set by GitHub Actions) | |
| # GitHub Actions will interpolate the JSON value | |
| tools_matrix_json = os.environ.get('TOOLS_MATRIX', '[]') | |
| try: | |
| tools_matrix = json.loads(tools_matrix_json) if tools_matrix_json and tools_matrix_json != '[]' else [] | |
| except json.JSONDecodeError: | |
| print(f"Warning: Could not parse TOOLS_MATRIX: {tools_matrix_json}") | |
| tools_matrix = [] | |
| errors = [] | |
| for tool in tools_matrix: | |
| if not tool.get('has_config', False): | |
| continue | |
| tool_dir = Path('Tools') / tool['tool_dir'] | |
| tool_name_lower = tool['tool_dir'].lower() | |
| config_path = find_config_file(tool_dir, tool_name_lower) | |
| if config_path: | |
| print(f"\nChecking {tool['display_name']} ({config_path})...") | |
| tool_errors = check_version_file(config_path, tool['display_name']) | |
| errors.extend(tool_errors) | |
| if errors: | |
| print("\n❌ Found version configuration errors:") | |
| for error in errors: | |
| print(f" {error}") | |
| exit(1) | |
| print("\n✓ All version files are valid") | |
| PYTHON_SCRIPT | |
| env: | |
| TOOLS_MATRIX: ${{ needs.detect-changes.outputs.tools_matrix }} | |
| # Build validation for all changed tools (matrix-based) | |
| build-tools: | |
| name: Build ${{ matrix.tool.display_name }} (${{ matrix.os }}) | |
| needs: [detect-changes, version-check] | |
| if: needs.detect-changes.outputs.any_tool == 'true' | |
| runs-on: ${{ matrix.os }} | |
| strategy: | |
| fail-fast: false | |
| matrix: | |
| tool: ${{ fromJson(needs.detect-changes.outputs.tools_matrix) }} | |
| os: [windows-latest, ubuntu-20.04] | |
| include: | |
| - os: windows-latest | |
| architecture: x64 | |
| - os: ubuntu-20.04 | |
| architecture: x64 | |
| steps: | |
| - uses: actions/checkout@v4 | |
| - name: Setup Python | |
| uses: actions/setup-python@v5 | |
| with: | |
| python-version: '3.8' | |
| - name: Build ${{ matrix.tool.display_name }} (validation) | |
| uses: ./.github/actions/build-tool | |
| with: | |
| tool_name: ${{ matrix.tool.build_name }} | |
| python_version: '3.8' | |
| architecture: ${{ matrix.architecture }} | |
| qt_version: ${{ matrix.tool.build_name == 'toolset' && 'PyQt5' || '' }} | |
| dry_run: ${{ github.event.inputs.full_build != 'true' && 'true' || 'false' }} | |
| upload_artifact: ${{ github.event.inputs.full_build == 'true' && 'true' || 'false' }} | |
| artifact_retention_days: '3' | |
| # Summary job that reports overall status | |
| build-summary: | |
| name: Build Summary | |
| needs: [detect-changes, version-check, build-tools] | |
| if: always() && needs.detect-changes.outputs.any_tool == 'true' | |
| runs-on: ubuntu-latest | |
| steps: | |
| - name: Check build results | |
| uses: actions/github-script@v7 | |
| with: | |
| script: | | |
| // Get tools matrix from detect-changes | |
| const toolsMatrixJson = '${{ needs.detect-changes.outputs.tools_matrix }}'; | |
| const toolsMatrix = JSON.parse(toolsMatrixJson || '[]'); | |
| // Get build-tools job result (matrix jobs return a single result) | |
| const buildToolsResult = '${{ needs.build-tools.result }}' || 'skipped'; | |
| let summary = '## 🔨 Build Validation Summary\n\n'; | |
| let hasFailures = false; | |
| let hasSkipped = false; | |
| // Version check | |
| const versionStatus = '${{ needs.version-check.result }}' || 'skipped'; | |
| if (versionStatus === 'success') { | |
| summary += '✅ **Version Check**: Passed\n'; | |
| } else if (versionStatus === 'failure') { | |
| summary += '❌ **Version Check**: Failed\n'; | |
| hasFailures = true; | |
| } else { | |
| summary += '⏭️ **Version Check**: Skipped\n'; | |
| } | |
| summary += '\n### Tool Builds\n\n'; | |
| // Process each tool | |
| // Note: For matrix jobs, we check the overall result | |
| // Individual matrix job results aren't easily accessible in summary | |
| // but the overall job will fail if any matrix job fails | |
| for (const tool of toolsMatrix) { | |
| if (buildToolsResult === 'success') { | |
| summary += `✅ **${tool.display_name}**: Build validated\n`; | |
| } else if (buildToolsResult === 'failure') { | |
| summary += `❌ **${tool.display_name}**: Build failed\n`; | |
| hasFailures = true; | |
| } else if (buildToolsResult === 'skipped' || !buildToolsResult) { | |
| summary += `⏭️ **${tool.display_name}**: Skipped\n`; | |
| hasSkipped = true; | |
| } else { | |
| summary += `⚠️ **${tool.display_name}**: ${buildToolsResult || 'Unknown'}\n`; | |
| } | |
| } | |
| summary += '\n---\n'; | |
| if (hasFailures) { | |
| summary += '\n⚠️ **Some builds failed.** Please review the logs and fix any issues before merging.\n'; | |
| } else if (hasSkipped) { | |
| summary += '\n📝 All validations passed. Ready for review.\n'; | |
| } else { | |
| summary += '\n🎉 All builds validated successfully! This PR is ready for merge.\n'; | |
| } | |
| // Post or update comment on PR | |
| if (context.eventName === 'pull_request') { | |
| const { owner, repo } = context.repo; | |
| const issue_number = context.issue.number; | |
| // Find existing comment | |
| const comments = await github.rest.issues.listComments({ | |
| owner, | |
| repo, | |
| issue_number | |
| }); | |
| const botComment = comments.data.find(c => | |
| c.user.type === 'Bot' && c.body.includes('Build Validation Summary') | |
| ); | |
| if (botComment) { | |
| await github.rest.issues.updateComment({ | |
| owner, | |
| repo, | |
| comment_id: botComment.id, | |
| body: summary | |
| }); | |
| } else { | |
| await github.rest.issues.createComment({ | |
| owner, | |
| repo, | |
| issue_number, | |
| body: summary | |
| }); | |
| } | |
| } | |
| // Fail if any builds failed | |
| if (hasFailures) { | |
| core.setFailed('One or more builds failed'); | |
| } |