Case-aware application #249
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 (directory or project name). Use "all-affected" for changed tools.' | |
| 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@v6 | |
| with: | |
| fetch-depth: 0 | |
| submodules: recursive | |
| - name: Setup Python | |
| uses: actions/setup-python@v6 | |
| with: | |
| python-version: '3.11' | |
| - name: Detect file changes | |
| id: changes | |
| run: | | |
| python3 << 'PYTHON_SCRIPT' | |
| import json | |
| import os | |
| import subprocess | |
| 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_root = tool_dir / "src" | |
| if not src_root.exists(): | |
| return None | |
| def scan_dir(candidate: Path) -> Path | None: | |
| config_py = candidate / "config.py" | |
| if config_py.exists(): | |
| return config_py | |
| config_info_py = candidate / "config" / "config_info.py" | |
| if config_info_py.exists(): | |
| return config_info_py | |
| main_py = candidate / "__main__.py" | |
| if main_py.exists() and "CURRENT_VERSION" in main_py.read_text(): | |
| return main_py | |
| init_py = candidate / "__init__.py" | |
| if init_py.exists() and "__version__" in init_py.read_text(): | |
| return init_py | |
| return None | |
| preferred = src_root / tool_name_lower | |
| if preferred.exists(): | |
| found = scan_dir(preferred) | |
| if found: | |
| return found | |
| for subdir in src_root.iterdir(): | |
| if not subdir.is_dir(): | |
| continue | |
| found = scan_dir(subdir) | |
| if found: | |
| return found | |
| 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 dynamically | |
| discover_cmd = ["python3", ".github/scripts/discover_tools.py", "--format", "json"] | |
| tools_json = subprocess.check_output(discover_cmd, text=True) | |
| all_tools = json.loads(tools_json) | |
| all_tool_infos = [] | |
| changed_tools = [] | |
| for tool in all_tools: | |
| tool_dir_name = tool["directory"] | |
| tool_path = tool.get("path") or tool.get("relative_path") or f"Tools/{tool_dir_name}" | |
| tool_dir = Path(tool_path) | |
| tool_changed = any(f.startswith(f"Tools/{tool_dir_name}/") for f in changed_files) | |
| tool_name_lower = tool_dir_name.lower() | |
| tool_info = { | |
| "tool_dir": tool_dir_name, | |
| "build_name": tool.get("build_name") or tool.get("name") or tool_dir_name, | |
| "display_name": tool.get("display_name") or tool_dir_name, | |
| "path": tool_path, | |
| "requires_qt": tool.get("requires_qt", False), | |
| "min_python": tool.get("min_python", "3.8"), | |
| "validate_py38": tool.get("validate_py38", True), | |
| "changed": tool_changed, | |
| "has_config": find_config_file(tool_dir, tool_name_lower) is not None, | |
| } | |
| all_tool_infos.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: | |
| changed_tools = list(all_tool_infos) | |
| # Filter by workflow input if provided | |
| tool_input = os.environ.get("INPUT_TOOL", "").strip().lower() | |
| if tool_input and tool_input != "all-affected": | |
| changed_tools = [ | |
| t for t in changed_tools | |
| if tool_input in {t["tool_dir"].lower(), t["build_name"].lower()} | |
| ] | |
| # Build tools matrix for GitHub Actions | |
| tools_matrix = [ | |
| { | |
| "tool_dir": t["tool_dir"], | |
| "build_name": t["build_name"], | |
| "display_name": t["display_name"], | |
| "path": t["path"], | |
| "requires_qt": t["requires_qt"], | |
| "has_config": t["has_config"], | |
| "min_python": t.get("min_python", "3.8"), | |
| "validate_py38": t.get("validate_py38", True), | |
| } | |
| for t in changed_tools | |
| ] | |
| 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 | |
| env: | |
| INPUT_TOOL: ${{ github.event.inputs.tool }} | |
| # 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@v6 | |
| with: | |
| submodules: recursive | |
| - name: Setup Python | |
| uses: actions/setup-python@v6 | |
| 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_root = tool_dir / "src" | |
| if not src_root.exists(): | |
| return None | |
| def scan_dir(candidate: Path) -> Path | None: | |
| config_py = candidate / "config.py" | |
| if config_py.exists(): | |
| return config_py | |
| config_info_py = candidate / "config" / "config_info.py" | |
| if config_info_py.exists(): | |
| return config_info_py | |
| main_py = candidate / "__main__.py" | |
| if main_py.exists() and "CURRENT_VERSION" in main_py.read_text(): | |
| return main_py | |
| init_py = candidate / "__init__.py" | |
| if init_py.exists() and "__version__" in init_py.read_text(): | |
| return init_py | |
| return None | |
| preferred = src_root / tool_name_lower | |
| if preferred.exists(): | |
| found = scan_dir(preferred) | |
| if found: | |
| return found | |
| for subdir in src_root.iterdir(): | |
| if not subdir.is_dir(): | |
| continue | |
| found = scan_dir(subdir) | |
| if found: | |
| return found | |
| 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 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@v6 | |
| with: | |
| submodules: recursive | |
| - name: Setup Python | |
| uses: actions/setup-python@v6 | |
| with: | |
| python-version: '3.8' | |
| - name: Skip Py3.8 validation (requires Python ${{ matrix.tool.min_python }}+) | |
| if: matrix.tool.validate_py38 != true | |
| run: | | |
| echo "Skipping Py3.8 dry-run: ${{ matrix.tool.display_name }} requires Python ${{ matrix.tool.min_python }}+" | |
| - name: Build ${{ matrix.tool.display_name }} (validation) | |
| if: matrix.tool.validate_py38 == true | |
| uses: ./.github/actions/build-tool | |
| with: | |
| tool_path: ${{ matrix.tool.path }} | |
| tool_display_name: ${{ matrix.tool.display_name }} | |
| tool_requires_qt: ${{ matrix.tool.requires_qt }} | |
| python_version: '3.8' | |
| architecture: ${{ matrix.architecture }} | |
| qt_api: ${{ matrix.tool.requires_qt && 'PyQt6' || '' }} | |
| 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@v9 | |
| 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'); | |
| } |