Files
actions/python-lint-fix/action.yml

389 lines
13 KiB
YAML

# 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@71cf2267d89c5cb81562390fa70a37fa40b1305e # v6-beta
with:
token: ${{ inputs.token || github.token }}
- name: Detect Python Version
id: python-version
uses: ivuorinen/actions/python-version-detect@0fa9a68f07a1260b321f814202658a6089a43d42
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: ivuorinen/actions/common-cache@0fa9a68f07a1260b321f814202658a6089a43d42
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: ivuorinen/actions/set-git-config@0fa9a68f07a1260b321f814202658a6089a43d42
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@014f16e7ab1402f30e7c3329d33797e7948572db # v4.31.3
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