# yaml-language-server: $schema=https://json.schemastore.org/github-action.json # permissions: # - contents: write # Required for committing and pushing lint fixes # - security-events: write # Required for uploading SARIF reports --- name: Python Lint and Fix description: 'Lints and fixes Python files, commits changes, and uploads SARIF report.' author: 'Ismo Vuorinen' branding: icon: 'code' color: 'yellow' inputs: python-version: description: 'Python version to use' required: false default: '3.11' flake8-version: description: 'Flake8 version to use' required: false default: '7.0.0' autopep8-version: description: 'Autopep8 version to use' required: false default: '2.0.4' max-retries: description: 'Maximum number of retry attempts for installations and linting' required: false default: '3' working-directory: description: 'Directory containing Python files to lint' required: false default: '.' fail-on-error: description: 'Whether to fail the action if linting errors are found' required: false default: 'true' token: description: 'GitHub token for authentication' required: false username: description: 'GitHub username for commits' required: false default: 'github-actions' email: description: 'GitHub email for commits' required: false default: 'github-actions@github.com' outputs: lint-result: description: 'Result of the linting process (success/failure)' value: ${{ steps.lint.outputs.result }} fixed-files: description: 'Number of files that were fixed' value: ${{ steps.fix.outputs.fixed_count }} error-count: description: 'Number of errors found' value: ${{ steps.lint.outputs.error_count }} runs: using: composite steps: - name: Validate Inputs id: validate shell: bash env: PYTHON_VERSION: ${{ inputs.python-version }} FLAKE8_VERSION: ${{ inputs.flake8-version }} AUTOPEP8_VERSION: ${{ inputs.autopep8-version }} WORKING_DIRECTORY: ${{ inputs.working-directory }} MAX_RETRIES: ${{ inputs.max-retries }} FAIL_ON_ERROR: ${{ inputs.fail-on-error }} EMAIL: ${{ inputs.email }} USERNAME: ${{ inputs.username }} GITHUB_TOKEN: ${{ inputs.token }} run: | set -euo pipefail # Validate Python version format if ! [[ "$PYTHON_VERSION" =~ ^[0-9]+\.[0-9]+(\.[0-9]+)?$ ]]; then echo "::error::Invalid python-version: '$PYTHON_VERSION'. Expected format: X.Y or X.Y.Z (e.g., 3.11, 3.11.5)" exit 1 fi # Validate flake8 version format (semantic versioning) if ! [[ "$FLAKE8_VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9.-]+)?$ ]]; then echo "::error::Invalid flake8-version: '$FLAKE8_VERSION'. Expected semantic version (e.g., 7.0.0)" exit 1 fi # Validate autopep8 version format (semantic versioning) if ! [[ "$AUTOPEP8_VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9.-]+)?$ ]]; then echo "::error::Invalid autopep8-version: '$AUTOPEP8_VERSION'. Expected semantic version (e.g., 2.0.4)" exit 1 fi # Validate working directory if [ ! -d "$WORKING_DIRECTORY" ]; then echo "::error::Invalid working-directory: '$WORKING_DIRECTORY'. Directory does not exist" exit 1 fi # Validate path security (prevent path traversal) if [[ "$WORKING_DIRECTORY" == *".."* ]]; then echo "::error::Invalid working-directory: '$WORKING_DIRECTORY'. Path traversal not allowed" exit 1 fi # Validate max-retries (positive integer) if ! [[ "$MAX_RETRIES" =~ ^[0-9]+$ ]] || [ "$MAX_RETRIES" -le 0 ]; then echo "::error::Invalid max-retries: '$MAX_RETRIES'. Must be a positive integer" exit 1 fi # Validate max-retries range if [ "$MAX_RETRIES" -gt 10 ]; then echo "::error::Invalid max-retries: '$MAX_RETRIES'. Maximum allowed is 10" exit 1 fi # Validate boolean inputs case "$FAIL_ON_ERROR" in true|false) ;; *) echo "::error::Invalid fail-on-error value: '$FAIL_ON_ERROR'. Expected: true or false" exit 1 ;; esac # Validate email format (basic check) if [[ "$EMAIL" != *"@"* ]] || [[ "$EMAIL" != *"."* ]]; then echo "::error::Invalid email format: '$EMAIL'. Expected valid email address" exit 1 fi # Validate username format (prevent command injection) if [[ "$USERNAME" == *";"* ]] || [[ "$USERNAME" == *"&&"* ]] || [[ "$USERNAME" == *"|"* ]]; then echo "::error::Invalid username: '$USERNAME'. Command injection patterns not allowed" exit 1 fi # Validate token format if provided (basic GitHub token pattern) if [[ -n "$GITHUB_TOKEN" ]]; then if ! [[ "$GITHUB_TOKEN" =~ ^gh[efpousr]_[a-zA-Z0-9]{36}$ ]]; then echo "::warning::GitHub token format may be invalid. Expected format: gh*_36characters" fi fi - name: Checkout Repository uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 with: token: ${{ inputs.token || github.token }} - name: Detect Python Version id: python-version uses: ./python-version-detect with: default-version: ${{ inputs.python-version }} - name: Setup Python uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6.0.0 with: python-version: ${{ steps.python-version.outputs.python-version }} cache: 'pip' cache-dependency-path: | **/requirements.txt **/requirements-dev.txt **/pyproject.toml **/setup.py - name: Check for Python Files id: check-files shell: bash env: WORKING_DIRECTORY: ${{ inputs.working-directory }} run: | set -euo pipefail cd "$WORKING_DIRECTORY" if ! find . -name "*.py" -type f -not -path "*/\.*" | grep -q .; then echo "No Python files found. Skipping lint and fix." echo "result=skipped" >> $GITHUB_OUTPUT exit 0 fi echo "result=found" >> $GITHUB_OUTPUT - name: Cache Python Dependencies if: steps.check-files.outputs.result == 'found' id: cache-pip uses: ./common-cache with: type: 'pip' paths: '~/.cache/pip' key-files: 'requirements*.txt,pyproject.toml,setup.py,setup.cfg' key-prefix: 'python-lint-fix' - name: Install Dependencies if: steps.check-files.outputs.result == 'found' && steps.cache-pip.outputs.cache-hit != 'true' id: install shell: bash env: MAX_RETRIES: ${{ inputs.max-retries }} FLAKE8_VERSION: ${{ inputs.flake8-version }} AUTOPEP8_VERSION: ${{ inputs.autopep8-version }} run: | set -euo pipefail function install_with_retry() { local package=$1 local version=$2 local attempt=1 local max_attempts="$MAX_RETRIES" while [ $attempt -le $max_attempts ]; do echo "Installing $package==$version (Attempt $attempt of $max_attempts)" if pip install "$package==$version"; then return 0 fi attempt=$((attempt + 1)) if [ $attempt -le $max_attempts ]; then echo "Installation failed, waiting 5 seconds before retry..." sleep 5 fi done echo "::error::Failed to install $package after $max_attempts attempts" return 1 } # Create virtual environment python -m venv .venv source .venv/bin/activate # Install dependencies with retry logic install_with_retry flake8 "$FLAKE8_VERSION" install_with_retry flake8-sarif 0.6.0 install_with_retry autopep8 "$AUTOPEP8_VERSION" # Verify installations flake8 --version || exit 1 autopep8 --version || exit 1 - name: Activate Virtual Environment (Cache Hit) if: steps.check-files.outputs.result == 'found' && steps.cache-pip.outputs.cache-hit == 'true' shell: bash env: FLAKE8_VERSION: ${{ inputs.flake8-version }} AUTOPEP8_VERSION: ${{ inputs.autopep8-version }} run: | set -euo pipefail # Create virtual environment if it doesn't exist from cache if [ ! -d ".venv" ]; then python -m venv .venv source .venv/bin/activate pip install "flake8==$FLAKE8_VERSION" "flake8-sarif==0.6.0" "autopep8==$AUTOPEP8_VERSION" fi - name: Run flake8 if: steps.check-files.outputs.result == 'found' id: lint shell: bash env: WORKING_DIRECTORY: ${{ inputs.working-directory }} FAIL_ON_ERROR: ${{ inputs.fail-on-error }} run: | set -euo pipefail source .venv/bin/activate cd "$WORKING_DIRECTORY" # Create temporary directory for reports mkdir -p reports # Run flake8 with error handling error_count=0 if ! flake8 --format=sarif --output-file=reports/flake8.sarif .; then error_count=$(grep -c "level\": \"error\"" reports/flake8.sarif || echo 0) echo "Found $error_count linting errors" echo "error_count=$error_count" >> $GITHUB_OUTPUT if [[ "$FAIL_ON_ERROR" == "true" ]]; then echo "::error::Linting failed with $error_count errors" echo "result=failure" >> $GITHUB_OUTPUT exit 1 fi fi echo "result=success" >> $GITHUB_OUTPUT echo "error_count=$error_count" >> $GITHUB_OUTPUT - name: Run autopep8 Fix if: steps.check-files.outputs.result == 'found' id: fix shell: bash env: WORKING_DIRECTORY: ${{ inputs.working-directory }} run: | set -euo pipefail source .venv/bin/activate cd "$WORKING_DIRECTORY" # Create temporary file for tracking changes touch /tmp/changed_files # Run autopep8 with change detection find . -name "*.py" -type f -not -path "*/\.*" | while read -r file; do if autopep8 --diff "$file" | grep -q '^[+-]'; then autopep8 --in-place "$file" echo "$file" >> /tmp/changed_files fi done # Count fixed files fixed_count=$(wc -l < /tmp/changed_files || echo 0) echo "Fixed $fixed_count files" echo "fixed_count=$fixed_count" >> $GITHUB_OUTPUT # Cleanup rm /tmp/changed_files - name: Set Git Config for Fixes if: ${{ fromJSON(steps.fix.outputs.fixed_count) > 0 }} uses: ./set-git-config with: token: ${{ inputs.token }} username: ${{ inputs.username }} email: ${{ inputs.email }} - name: Commit Fixes if: ${{ fromJSON(steps.fix.outputs.fixed_count) > 0 }} shell: bash env: WORKING_DIRECTORY: ${{ inputs.working-directory }} MAX_RETRIES: ${{ inputs.max-retries }} FIXED_COUNT: ${{ steps.fix.outputs.fixed_count }} run: | set -euo pipefail cd "$WORKING_DIRECTORY" # Commit changes with retry logic attempt=1 max_attempts="$MAX_RETRIES" while [ $attempt -le $max_attempts ]; do echo "Attempting to commit and push changes (Attempt $attempt of $max_attempts)" git add . git commit -m "fix: applied python lint fixes to $FIXED_COUNT files" if git pull --rebase && git push; then echo "Successfully pushed changes" break fi attempt=$((attempt + 1)) if [ $attempt -le $max_attempts ]; then echo "Push failed, waiting 5 seconds before retry..." sleep 5 else echo "::error::Failed to push changes after $max_attempts attempts" exit 1 fi done - name: Upload SARIF Report if: steps.check-files.outputs.result == 'found' uses: github/codeql-action/upload-sarif@16140ae1a102900babc80a33c44059580f687047 # v4.30.9 with: sarif_file: ${{ inputs.working-directory }}/reports/flake8.sarif category: 'python-lint' - name: Cleanup if: always() shell: bash run: |- set -euo pipefail # Remove virtual environment rm -rf .venv # Remove temporary files rm -rf reports