mirror of
https://github.com/ivuorinen/actions.git
synced 2026-01-26 03:23:59 +00:00
389 lines
13 KiB
YAML
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@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
|
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@0499de31b99561a6d14a36a5f662c2a54f91beee # v4.31.2
|
|
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
|