Skip to content

test(pykotor): regression coverage for case-aware install detection #317

test(pykotor): regression coverage for case-aware install detection

test(pykotor): regression coverage for case-aware install detection #317

Workflow file for this run

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');
}