diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b424006..b361e7a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -36,31 +36,36 @@ jobs: run: | git config --global core.autocrlf false + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.x' + - name: Verify script executability run: | - chmod +x scripts/**/*.sh || true + chmod +x scripts/**/*.py || true - name: Required validations run: | set -e - scripts/validate/manifest.sh - scripts/validate/xml_wellformed.sh - scripts/validate/workflows.sh + python3 scripts/validate/manifest.py + python3 scripts/validate/xml_wellformed.py + python3 scripts/validate/workflows.py - name: Optional validations run: | set +e - scripts/validate/changelog.sh - scripts/validate/language_structure.sh - scripts/validate/license_headers.sh - scripts/validate/no_secrets.sh - scripts/validate/paths.sh - scripts/validate/php_syntax.sh - scripts/validate/tabs.sh - scripts/validate/version_alignment.sh - scripts/validate/version_hierarchy.sh + python3 scripts/validate/changelog.py || echo "changelog validation not yet converted" + python3 scripts/validate/language_structure.py || echo "language_structure validation not yet converted" + python3 scripts/validate/license_headers.py || echo "license_headers validation not yet converted" + python3 scripts/validate/no_secrets.py + python3 scripts/validate/paths.py + python3 scripts/validate/php_syntax.py + python3 scripts/validate/tabs.py + python3 scripts/validate/version_alignment.py || echo "version_alignment validation not yet converted" + python3 scripts/validate/version_hierarchy.py || echo "version_hierarchy validation not yet converted" - name: CI summary if: always() diff --git a/.github/workflows/deploy_staging.yml b/.github/workflows/deploy_staging.yml index a2c7741..82ce92c 100644 --- a/.github/workflows/deploy_staging.yml +++ b/.github/workflows/deploy_staging.yml @@ -53,26 +53,31 @@ jobs: exit 1 fi + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.x' + - name: Run pre-deployment validations run: | - chmod +x scripts/validate/*.sh + chmod +x scripts/validate/*.py # Required validations - scripts/validate/manifest.sh - scripts/validate/xml_wellformed.sh - scripts/validate/php_syntax.sh + python3 scripts/validate/manifest.py + python3 scripts/validate/xml_wellformed.py + python3 scripts/validate/php_syntax.py - name: Build deployment package id: build run: | - chmod +x scripts/release/package_extension.sh + chmod +x scripts/release/package_extension.py VERSION="${{ inputs.version }}" if [ -z "${VERSION}" ]; then VERSION=$(grep -oP '\K[^<]+' src/templates/templateDetails.xml | head -n 1) fi - scripts/release/package_extension.sh dist "${VERSION}" + python3 scripts/release/package_extension.py dist "${VERSION}" ZIP_FILE=$(ls dist/*.zip | head -n 1) echo "package=${ZIP_FILE}" >> "$GITHUB_OUTPUT" diff --git a/.github/workflows/release_pipeline.yml b/.github/workflows/release_pipeline.yml index 52ec397..d83500a 100644 --- a/.github/workflows/release_pipeline.yml +++ b/.github/workflows/release_pipeline.yml @@ -103,21 +103,20 @@ jobs: # Check if REF_NAME is main or matches version pattern if [ "${REF_NAME}" = "main" ]; then # Infer version from manifest when on main branch - SCRIPT_LIB_DIR="${GITHUB_WORKSPACE}/scripts/lib" - if [ ! -f "${SCRIPT_LIB_DIR}/joomla_manifest.sh" ]; then - echo "ERROR: Cannot find joomla_manifest.sh library" >> "${GITHUB_STEP_SUMMARY}" - exit 1 - fi - - # Source the library functions - . "${SCRIPT_LIB_DIR}/joomla_manifest.sh" - - # Find and extract version from manifest - MANIFEST="$(find_manifest "${GITHUB_WORKSPACE}/src")" - VERSION="$(get_manifest_version "${MANIFEST}")" + # Use Python library for cross-platform compatibility + VERSION=$(python3 -c " +import sys +sys.path.insert(0, '${GITHUB_WORKSPACE}/scripts/lib') +import extension_utils +ext_info = extension_utils.get_extension_info('${GITHUB_WORKSPACE}/src') +if ext_info: + print(ext_info.version) +else: + sys.exit(1) + ") if [ -z "${VERSION}" ]; then - echo "ERROR: Failed to extract version from manifest: ${MANIFEST}" >> "${GITHUB_STEP_SUMMARY}" + echo "ERROR: Failed to extract version from manifest" >> "${GITHUB_STEP_SUMMARY}" exit 1 fi @@ -552,19 +551,19 @@ jobs: set -euo pipefail required_scripts=( - "scripts/validate/manifest.sh" - "scripts/validate/xml_wellformed.sh" + "scripts/validate/manifest.py" + "scripts/validate/xml_wellformed.py" ) optional_scripts=( - "scripts/validate/changelog.sh" - "scripts/validate/language_structure.sh" - "scripts/validate/license_headers.sh" - "scripts/validate/no_secrets.sh" - "scripts/validate/paths.sh" - "scripts/validate/php_syntax.sh" - "scripts/validate/tabs.sh" - "scripts/validate/version_alignment.sh" + "scripts/validate/changelog.py" + "scripts/validate/language_structure.py" + "scripts/validate/license_headers.py" + "scripts/validate/no_secrets.py" + "scripts/validate/paths.py" + "scripts/validate/php_syntax.py" + "scripts/validate/tabs.py" + "scripts/validate/version_alignment.py" ) missing=() @@ -596,7 +595,7 @@ jobs: for s in "${required_scripts[@]}" "${optional_scripts[@]}"; do if [ -f "${s}" ]; then chmod +x "${s}" - "${s}" >> "${GITHUB_STEP_SUMMARY}" + python3 "${s}" >> "${GITHUB_STEP_SUMMARY}" ran+=("${s}") else skipped+=("${s}") diff --git a/Makefile b/Makefile index 7e302f9..b79bf4a 100644 --- a/Makefile +++ b/Makefile @@ -34,14 +34,14 @@ install: ## validate: Run all validation scripts validate: @echo "Running validation scripts..." - @./scripts/run/validate_all.sh + @python3 ./scripts/run/validate_all.py ## validate-required: Run only required validation scripts validate-required: @echo "Running required validations..." - @./scripts/validate/manifest.sh - @./scripts/validate/xml_wellformed.sh - @./scripts/validate/workflows.sh + @python3 ./scripts/validate/manifest.py + @python3 ./scripts/validate/xml_wellformed.py + @python3 ./scripts/validate/workflows.py @echo "✓ Required validations passed" ## test: Run all tests @@ -96,18 +96,18 @@ phpcompat: ## package: Create distribution package package: @echo "Creating distribution package..." - @./scripts/release/package_extension.sh dist $(VERSION) + @python3 ./scripts/release/package_extension.py dist $(VERSION) @echo "✓ Package created: dist/moko-cassiopeia-$(VERSION)-*.zip" ## smoke-test: Run smoke tests smoke-test: @echo "Running smoke tests..." - @./scripts/run/smoke_test.sh + @python3 ./scripts/run/smoke_test.py ## script-health: Check script health script-health: @echo "Checking script health..." - @./scripts/run/script_health.sh + @python3 ./scripts/run/script_health.py ## version-check: Display current version information version-check: @@ -119,7 +119,7 @@ version-check: ## fix-permissions: Fix script executable permissions fix-permissions: @echo "Fixing script permissions..." - @find scripts -type f -name "*.sh" -exec chmod +x {} \; + @find scripts -type f -name "*.py" -exec chmod +x {} \; @echo "✓ Permissions fixed" ## clean: Remove generated files and caches @@ -174,13 +174,10 @@ watch: ## list-scripts: List all available scripts list-scripts: @echo "Available validation scripts:" - @find scripts/validate -type f -name "*.sh" -exec basename {} \; | sort + @find scripts/validate -type f -name "*.py" -exec basename {} \; | sort @echo "" @echo "Available fix scripts:" - @find scripts/fix -type f -name "*.sh" -exec basename {} \; | sort - @echo "" - @echo "Available run scripts (bash):" - @find scripts/run -type f -name "*.sh" -exec basename {} \; | sort + @find scripts/fix -type f -name "*.py" -exec basename {} \; | sort @echo "" @echo "Available run scripts (python):" @find scripts/run -type f -name "*.py" -exec basename {} \; | sort diff --git a/scripts/validate/no_secrets.py b/scripts/validate/no_secrets.py old mode 100644 new mode 100755 diff --git a/scripts/validate/paths.py b/scripts/validate/paths.py new file mode 100755 index 0000000..8fadf1d --- /dev/null +++ b/scripts/validate/paths.py @@ -0,0 +1,169 @@ +#!/usr/bin/env python3 +""" +Detect Windows-style path separators (backslashes). + +Copyright (C) 2025 Moko Consulting + +This file is part of a Moko Consulting project. + +SPDX-License-Identifier: GPL-3.0-or-later + +This program is free software; you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation; either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program (./LICENSE.md). + +FILE INFORMATION +DEFGROUP: Script.Validate +INGROUP: Path.Normalization +REPO: https://github.com/mokoconsulting-tech/moko-cassiopeia +PATH: /scripts/validate/paths.py +VERSION: 01.00.00 +BRIEF: Detect Windows-style path separators (backslashes) +NOTE: Ensures cross-platform path compatibility +""" + +import mimetypes +import subprocess +import sys +from pathlib import Path +from typing import List, Tuple, Dict + +# Add lib directory to path +sys.path.insert(0, str(Path(__file__).parent.parent / "lib")) + +try: + import common +except ImportError: + print("ERROR: Cannot import required libraries", file=sys.stderr) + sys.exit(1) + + +def get_tracked_files() -> List[str]: + """ + Get list of files tracked by git. + + Returns: + List of file paths + """ + try: + result = common.run_command( + ["git", "ls-files", "-z"], + capture_output=True, + check=True + ) + files = [f for f in result.stdout.split('\0') if f.strip()] + return files + except subprocess.CalledProcessError: + return [] + + +def is_binary_file(filepath: str) -> bool: + """ + Check if a file is likely binary. + + Args: + filepath: Path to file + + Returns: + True if likely binary + """ + # Check mime type + mime_type, _ = mimetypes.guess_type(filepath) + if mime_type and mime_type.startswith(('application/', 'audio/', 'image/', 'video/')): + return True + + # Check for null bytes (heuristic for binary files) + try: + with open(filepath, 'rb') as f: + chunk = f.read(1024) + if b'\x00' in chunk: + return True + except Exception: + return True + + return False + + +def find_backslashes_in_file(filepath: str) -> List[Tuple[int, str]]: + """ + Find lines with backslashes in a file. + + Args: + filepath: Path to file + + Returns: + List of (line_number, line_content) tuples + """ + backslashes = [] + + try: + with open(filepath, 'r', encoding='utf-8', errors='ignore') as f: + for line_num, line in enumerate(f, 1): + if '\\' in line: + backslashes.append((line_num, line.rstrip())) + except Exception as e: + common.log_warn(f"Could not read {filepath}: {e}") + + return backslashes + + +def main() -> int: + """Main entry point.""" + tracked_files = get_tracked_files() + + if not tracked_files: + print("No files to check") + return 0 + + hits: Dict[str, List[Tuple[int, str]]] = {} + + for filepath in tracked_files: + # Skip binary files + if is_binary_file(filepath): + continue + + # Find backslashes + backslashes = find_backslashes_in_file(filepath) + if backslashes: + hits[filepath] = backslashes + + if hits: + print("ERROR: Windows-style path literals detected", file=sys.stderr) + print("", file=sys.stderr) + print(f"Found backslashes in {len(hits)} file(s):", file=sys.stderr) + + for filepath, lines in hits.items(): + print("", file=sys.stderr) + print(f" File: {filepath}", file=sys.stderr) + print(" Lines with backslashes:", file=sys.stderr) + + # Show first 5 lines + for line_num, line_content in lines[:5]: + print(f" {line_num}: {line_content[:80]}", file=sys.stderr) + + if len(lines) > 5: + print(f" ... and {len(lines) - 5} more", file=sys.stderr) + + print("", file=sys.stderr) + print("To fix:", file=sys.stderr) + print(" 1. Run: python3 scripts/fix/paths.py", file=sys.stderr) + print(" 2. Or manually replace backslashes (\\) with forward slashes (/)", file=sys.stderr) + print(" 3. Ensure paths use POSIX separators for cross-platform compatibility", file=sys.stderr) + print("", file=sys.stderr) + return 2 + + print("paths: ok") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/validate/tabs.py b/scripts/validate/tabs.py old mode 100644 new mode 100755 diff --git a/scripts/validate/workflows.py b/scripts/validate/workflows.py new file mode 100755 index 0000000..9473a9f --- /dev/null +++ b/scripts/validate/workflows.py @@ -0,0 +1,217 @@ +#!/usr/bin/env python3 +""" +Validate GitHub Actions workflow files. + +Copyright (C) 2025 Moko Consulting + +This file is part of a Moko Consulting project. + +SPDX-License-Identifier: GPL-3.0-or-later + +This program is free software; you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation; either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program (./LICENSE.md). + +FILE INFORMATION +DEFGROUP: Script.Validate +INGROUP: CI.Validation +REPO: https://github.com/mokoconsulting-tech/moko-cassiopeia +PATH: /scripts/validate/workflows.py +VERSION: 01.00.00 +BRIEF: Validate GitHub Actions workflow files +NOTE: Checks YAML syntax, structure, and best practices +""" + +import sys +from pathlib import Path +from typing import List, Tuple + +# Add lib directory to path +sys.path.insert(0, str(Path(__file__).parent.parent / "lib")) + +try: + import common +except ImportError: + print("ERROR: Cannot import required libraries", file=sys.stderr) + sys.exit(1) + + +def validate_yaml_syntax(filepath: Path) -> bool: + """ + Validate YAML syntax of a workflow file. + + Args: + filepath: Path to workflow file + + Returns: + True if valid + """ + try: + import yaml + except ImportError: + common.log_warn("PyYAML module not installed. Install with: pip3 install pyyaml") + return True # Skip validation if yaml not available + + try: + with open(filepath, 'r', encoding='utf-8') as f: + yaml.safe_load(f) + print(f"✓ Valid YAML: {filepath.name}") + return True + except yaml.YAMLError as e: + print(f"✗ YAML Error in {filepath.name}: {e}", file=sys.stderr) + return False + except Exception as e: + print(f"✗ Error reading {filepath.name}: {e}", file=sys.stderr) + return False + + +def check_no_tabs(filepath: Path) -> bool: + """ + Check that file contains no tab characters. + + Args: + filepath: Path to file + + Returns: + True if no tabs found + """ + try: + with open(filepath, 'r', encoding='utf-8') as f: + content = f.read() + if '\t' in content: + common.log_error(f"✗ File contains tab characters: {filepath.name}") + return False + except Exception as e: + common.log_warn(f"Could not read {filepath}: {e}") + return False + + return True + + +def check_workflow_structure(filepath: Path) -> bool: + """ + Check workflow file structure for required keys. + + Args: + filepath: Path to workflow file + + Returns: + True if structure is valid + """ + errors = 0 + + try: + with open(filepath, 'r', encoding='utf-8') as f: + content = f.read() + + # Check for required top-level keys + if 'name:' not in content and not content.startswith('name:'): + common.log_warn(f"Missing 'name:' in {filepath.name}") + + if 'on:' not in content and not content.startswith('on:'): + common.log_error(f"✗ Missing 'on:' trigger in {filepath.name}") + errors += 1 + + if 'jobs:' not in content and not content.startswith('jobs:'): + common.log_error(f"✗ Missing 'jobs:' in {filepath.name}") + errors += 1 + + except Exception as e: + common.log_error(f"Error reading {filepath}: {e}") + return False + + return errors == 0 + + +def validate_workflow_file(filepath: Path) -> bool: + """ + Validate a single workflow file. + + Args: + filepath: Path to workflow file + + Returns: + True if valid + """ + common.log_info(f"Validating: {filepath.name}") + + errors = 0 + + # Check YAML syntax + if not validate_yaml_syntax(filepath): + errors += 1 + + # Check for tabs + if not check_no_tabs(filepath): + errors += 1 + + # Check structure + if not check_workflow_structure(filepath): + errors += 1 + + if errors == 0: + common.log_info(f"✓ {filepath.name} passed all checks") + return True + else: + common.log_error(f"✗ {filepath.name} failed {errors} check(s)") + return False + + +def main() -> int: + """Main entry point.""" + common.log_info("GitHub Actions Workflow Validation") + common.log_info("===================================") + print() + + workflows_dir = Path(".github/workflows") + + if not workflows_dir.is_dir(): + common.log_error(f"Workflows directory not found: {workflows_dir}") + return 1 + + # Find all workflow files + workflow_files = [] + for pattern in ["*.yml", "*.yaml"]: + workflow_files.extend(workflows_dir.glob(pattern)) + + if not workflow_files: + common.log_warn("No workflow files found") + return 0 + + total = len(workflow_files) + passed = 0 + failed = 0 + + for workflow in workflow_files: + if validate_workflow_file(workflow): + passed += 1 + else: + failed += 1 + print() + + common.log_info("===================================") + common.log_info("Summary:") + common.log_info(f" Total workflows: {total}") + common.log_info(f" Passed: {passed}") + common.log_info(f" Failed: {failed}") + common.log_info("===================================") + + if failed > 0: + common.log_error("Workflow validation failed") + return 1 + + common.log_info("All workflows validated successfully") + return 0 + + +if __name__ == "__main__": + sys.exit(main())