# yaml-language-server: $schema=https://json.schemastore.org/github-action.json # permissions: # - contents: write # Required for fix mode to push changes # - security-events: write # Required for check mode to upload SARIF --- name: ESLint Lint description: 'Run ESLint in check or fix mode with advanced configuration and reporting' author: Ismo Vuorinen branding: icon: check-circle color: blue inputs: mode: description: 'Mode to run (check or fix)' required: false default: 'check' working-directory: description: 'Directory containing files to lint' required: false default: '.' eslint-version: description: 'ESLint version to use' required: false default: 'latest' config-file: description: 'Path to ESLint config file' required: false default: '.eslintrc' ignore-file: description: 'Path to ESLint ignore file' required: false default: '.eslintignore' file-extensions: description: 'File extensions to lint (comma-separated)' required: false default: '.js,.jsx,.ts,.tsx' cache: description: 'Enable ESLint caching' required: false default: 'true' max-warnings: description: 'Maximum number of warnings allowed (check mode only)' required: false default: '0' fail-on-error: description: 'Fail workflow if issues are found (check mode only)' required: false default: 'true' report-format: description: 'Output format for check mode (stylish, json, sarif)' required: false default: 'sarif' max-retries: description: 'Maximum number of retry attempts' required: false default: '3' token: description: 'GitHub token for authentication' required: false default: '' username: description: 'GitHub username for commits (fix mode only)' required: false default: 'github-actions' email: description: 'GitHub email for commits (fix mode only)' required: false default: 'github-actions@github.com' outputs: status: description: 'Overall status (success/failure)' value: ${{ steps.check.outputs.status || steps.fix.outputs.status }} error-count: description: 'Number of errors found (check mode only)' value: ${{ steps.check.outputs.error_count }} warning-count: description: 'Number of warnings found (check mode only)' value: ${{ steps.check.outputs.warning_count }} sarif-file: description: 'Path to SARIF report file (check mode only)' value: ${{ steps.check.outputs.sarif_file }} files-checked: description: 'Number of files checked (check mode only)' value: ${{ steps.check.outputs.files_checked }} files-changed: description: 'Number of files changed (fix mode only)' value: ${{ steps.fix.outputs.files_changed }} errors-fixed: description: 'Number of errors fixed (fix mode only)' value: ${{ steps.fix.outputs.errors_fixed }} runs: using: composite steps: - name: Validate Inputs id: validate shell: sh env: MODE: ${{ inputs.mode }} WORKING_DIRECTORY: ${{ inputs.working-directory }} ESLINT_VERSION: ${{ inputs.eslint-version }} CONFIG_FILE: ${{ inputs.config-file }} IGNORE_FILE: ${{ inputs.ignore-file }} FILE_EXTENSIONS: ${{ inputs.file-extensions }} CACHE: ${{ inputs.cache }} FAIL_ON_ERROR: ${{ inputs.fail-on-error }} MAX_WARNINGS: ${{ inputs.max-warnings }} REPORT_FORMAT: ${{ inputs.report-format }} MAX_RETRIES: ${{ inputs.max-retries }} EMAIL: ${{ inputs.email }} USERNAME: ${{ inputs.username }} run: | set -eu # Validate mode case "$MODE" in "check"|"fix") echo "Mode: $MODE" ;; *) echo "::error::Invalid mode: '$MODE'. Must be 'check' or 'fix'" exit 1 ;; esac # Validate working directory exists if [ ! -d "$WORKING_DIRECTORY" ]; then echo "::error::Working directory not found at '$WORKING_DIRECTORY'" exit 1 fi # Validate working directory path security (prevent traversal) case "$WORKING_DIRECTORY" in *..*) echo "::error::Invalid working directory path: '$WORKING_DIRECTORY'. Path traversal not allowed" exit 1 ;; esac # Validate ESLint version format if [ -n "$ESLINT_VERSION" ] && [ "$ESLINT_VERSION" != "latest" ]; then if ! echo "$ESLINT_VERSION" | grep -Eq '^[0-9]+\.[0-9]+(\.[0-9]+)?(-[a-zA-Z0-9]+([.-][a-zA-Z0-9]+)*)?$'; then echo "::error::Invalid eslint-version format: '$ESLINT_VERSION'. Expected format: X.Y.Z or X.Y.Z-prerelease or 'latest' (e.g., 8.57.0, 8.57.0-rc.1, latest)" exit 1 fi fi # Validate config file path if not default if [ "$CONFIG_FILE" != ".eslintrc" ]; then case "$CONFIG_FILE" in *..*) echo "::error::Invalid config file path: '$CONFIG_FILE'. Path traversal not allowed" exit 1 ;; esac fi # Validate ignore file path if not default if [ "$IGNORE_FILE" != ".eslintignore" ]; then case "$IGNORE_FILE" in *..*) echo "::error::Invalid ignore file path: '$IGNORE_FILE'. Path traversal not allowed" exit 1 ;; esac fi # Validate file extensions format (must start with . and contain letters/numbers) if ! echo "$FILE_EXTENSIONS" | grep -Eq '^\.[a-zA-Z0-9]+(,\.[a-zA-Z0-9]+)*$'; then echo "::error::Invalid file extensions format: '$FILE_EXTENSIONS'. Expected format: .js,.jsx,.ts,.tsx" exit 1 fi # Validate boolean inputs validate_boolean() { value="$1" name="$2" case "$value" in true|True|TRUE|false|False|FALSE) ;; *) echo "::error::Invalid boolean value for $name: '$value'. Expected: true or false" exit 1 ;; esac } validate_boolean "$CACHE" "cache" validate_boolean "$FAIL_ON_ERROR" "fail-on-error" # Validate max warnings (non-negative integer) case "$MAX_WARNINGS" in ''|*[!0-9]*) echo "::error::Invalid max-warnings: '$MAX_WARNINGS'. Must be a non-negative integer" exit 1 ;; esac # Validate report format case "$REPORT_FORMAT" in stylish|json|sarif) ;; *) echo "::error::Invalid report-format: '$REPORT_FORMAT'. Must be one of: stylish, json, sarif" exit 1 ;; esac # Validate max retries case "$MAX_RETRIES" in ''|*[!0-9]*) echo "::error::Invalid max-retries: '$MAX_RETRIES'. Must be a positive integer between 1 and 10" exit 1 ;; esac if [ "$MAX_RETRIES" -le 0 ] || [ "$MAX_RETRIES" -gt 10 ]; then echo "::error::Invalid max-retries: '$MAX_RETRIES'. Must be a positive integer between 1 and 10" exit 1 fi # Validate email and username for fix mode if [ "$MODE" = "fix" ]; then if ! echo "$EMAIL" | grep -Eq '^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$'; then echo "::error::Invalid email format: '$EMAIL'. Expected valid email address (e.g., user@example.com)" exit 1 fi # Validate username format (GitHub canonical rules) username="$USERNAME" if [ ${#username} -gt 39 ]; then echo "::error::Username too long: ${#username} characters. GitHub usernames are max 39 characters" exit 1 fi case "$username" in *[!a-zA-Z0-9-]*) echo "::error::Invalid username characters in '$username'. Only letters, digits, and hyphens allowed" exit 1 ;; esac case "$username" in -*|*-) echo "::error::Invalid username '$username'. Cannot start or end with hyphen" exit 1 ;; esac case "$username" in *--*) echo "::error::Invalid username '$username'. Consecutive hyphens not allowed" exit 1 ;; esac fi echo "Input validation completed successfully" - name: Checkout Repository uses: actions/checkout@71cf2267d89c5cb81562390fa70a37fa40b1305e # v6-beta with: token: ${{ inputs.token || github.token }} - name: Detect Package Manager id: detect-pm shell: sh run: | set -eu # Detect package manager from lockfiles if [ -f bun.lockb ]; then package_manager="bun" elif [ -f pnpm-lock.yaml ]; then package_manager="pnpm" elif [ -f yarn.lock ]; then package_manager="yarn" else package_manager="npm" fi printf 'package-manager=%s\n' "$package_manager" >> "$GITHUB_OUTPUT" echo "Detected package manager: $package_manager" - name: Setup Node.js uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0 with: node-version: '24' - name: Enable Corepack shell: sh run: | set -eu corepack enable - name: Install Package Manager shell: sh env: PACKAGE_MANAGER: ${{ steps.detect-pm.outputs.package-manager }} run: | set -eu case "$PACKAGE_MANAGER" in pnpm) corepack prepare pnpm@latest --activate ;; yarn) corepack prepare yarn@stable --activate ;; bun|npm) # Bun installed separately, npm built-in ;; esac - name: Setup Bun if: steps.detect-pm.outputs.package-manager == 'bun' uses: oven-sh/setup-bun@735343b667d3e6f658f44d0eca948eb6282f2b76 # v2.0.2 with: bun-version: latest - name: Cache Node Dependencies id: cache uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1 with: path: node_modules key: ${{ runner.os }}-eslint-lint-${{ inputs.mode }}-${{ steps.detect-pm.outputs.package-manager }}-${{ hashFiles('package-lock.json', 'yarn.lock', 'pnpm-lock.yaml', 'bun.lockb') }} restore-keys: | ${{ runner.os }}-eslint-lint-${{ inputs.mode }}-${{ steps.detect-pm.outputs.package-manager }}- ${{ runner.os }}-eslint-lint-${{ inputs.mode }}- - name: Install Dependencies if: steps.cache.outputs.cache-hit != 'true' shell: sh env: PACKAGE_MANAGER: ${{ steps.detect-pm.outputs.package-manager }} run: | set -eu echo "Installing dependencies using $PACKAGE_MANAGER..." case "$PACKAGE_MANAGER" in "pnpm") pnpm install --frozen-lockfile ;; "yarn") if [ -f ".yarnrc.yml" ]; then yarn install --immutable else yarn install --frozen-lockfile fi ;; "bun") bun install --frozen-lockfile ;; "npm"|*) npm ci ;; esac echo "✅ Dependencies installed successfully" - name: Run ESLint Check if: inputs.mode == 'check' id: check shell: sh working-directory: ${{ inputs.working-directory }} env: PACKAGE_MANAGER: ${{ steps.detect-pm.outputs.package-manager }} ESLINT_VERSION: ${{ inputs.eslint-version }} CONFIG_FILE: ${{ inputs.config-file }} CACHE: ${{ inputs.cache }} MAX_WARNINGS: ${{ inputs.max-warnings }} FAIL_ON_ERROR: ${{ inputs.fail-on-error }} REPORT_FORMAT: ${{ inputs.report-format }} FILE_EXTENSIONS: ${{ inputs.file-extensions }} run: | set -eu echo "Running ESLint check mode..." # Build ESLint command based on package manager case "$PACKAGE_MANAGER" in "pnpm") eslint_cmd="pnpm exec eslint . --ext $FILE_EXTENSIONS" ;; "yarn") eslint_cmd="yarn eslint . --ext $FILE_EXTENSIONS" ;; "bun") eslint_cmd="bunx eslint . --ext $FILE_EXTENSIONS" ;; "npm"|*) eslint_cmd="npx eslint . --ext $FILE_EXTENSIONS" ;; esac # Add config file if specified if [ "$CONFIG_FILE" != ".eslintrc" ] && [ -f "$CONFIG_FILE" ]; then eslint_cmd="$eslint_cmd --config $CONFIG_FILE" fi # Add cache option if [ "$CACHE" = "true" ]; then eslint_cmd="$eslint_cmd --cache" fi # Add max warnings eslint_cmd="$eslint_cmd --max-warnings $MAX_WARNINGS" # Add format if [ "$REPORT_FORMAT" = "sarif" ]; then eslint_cmd="$eslint_cmd --format @microsoft/eslint-formatter-sarif --output-file eslint-results.sarif" else eslint_cmd="$eslint_cmd --format $REPORT_FORMAT" fi # Run ESLint and capture exit code eslint_exit_code=0 eval "$eslint_cmd" || eslint_exit_code=$? # Parse results if [ "$REPORT_FORMAT" = "sarif" ] && [ -f eslint-results.sarif ]; then error_count=$(jq '[.runs[]?.results[]? | select(.level == "error")] | length' eslint-results.sarif 2>/dev/null || echo "0") warning_count=$(jq '[.runs[]?.results[]? | select(.level == "warning")] | length' eslint-results.sarif 2>/dev/null || echo "0") files_checked=$(jq '[.runs[]?.results[]?.locations[]?.physicalLocation?.artifactLocation?.uri] | unique | length' eslint-results.sarif 2>/dev/null || echo "0") sarif_file="eslint-results.sarif" else error_count="0" warning_count="0" files_checked="0" sarif_file="" fi # Set outputs if [ $eslint_exit_code -eq 0 ]; then echo "status=success" >> "$GITHUB_OUTPUT" else echo "status=failure" >> "$GITHUB_OUTPUT" fi echo "error_count=$error_count" >> "$GITHUB_OUTPUT" echo "warning_count=$warning_count" >> "$GITHUB_OUTPUT" echo "files_checked=$files_checked" >> "$GITHUB_OUTPUT" echo "sarif_file=$sarif_file" >> "$GITHUB_OUTPUT" echo "✅ ESLint check completed: $error_count errors, $warning_count warnings" # Exit with eslint's exit code if fail-on-error is true if [ "$FAIL_ON_ERROR" = "true" ]; then exit $eslint_exit_code fi - name: Upload SARIF Report if: inputs.mode == 'check' && inputs.report-format == 'sarif' && always() uses: github/codeql-action/upload-sarif@5d4e8d1aca955e8d8589aabd499c5cae939e33c7 # v4.31.9 with: sarif_file: ${{ inputs.working-directory }}/eslint-results.sarif - name: Run ESLint Fix if: inputs.mode == 'fix' id: fix shell: sh working-directory: ${{ inputs.working-directory }} env: PACKAGE_MANAGER: ${{ steps.detect-pm.outputs.package-manager }} FILE_EXTENSIONS: ${{ inputs.file-extensions }} run: | set -eu echo "Running ESLint fix mode..." # Count files before fix files_before=$(git status --porcelain | wc -l | tr -d ' ') # Run ESLint fix based on package manager case "$PACKAGE_MANAGER" in "pnpm") pnpm exec eslint . --ext $FILE_EXTENSIONS --fix || true ;; "yarn") yarn eslint . --ext $FILE_EXTENSIONS --fix || true ;; "bun") bunx eslint . --ext $FILE_EXTENSIONS --fix || true ;; "npm"|*) npx eslint . --ext $FILE_EXTENSIONS --fix || true ;; esac # Count files after fix files_after=$(git status --porcelain | wc -l | tr -d ' ') files_changed=$((files_after - files_before)) # Get number of errors fixed (approximate from diff) errors_fixed=$(git diff --numstat | wc -l | tr -d ' ') printf '%s\n' "files_changed=$files_changed" >> "$GITHUB_OUTPUT" printf '%s\n' "errors_fixed=$errors_fixed" >> "$GITHUB_OUTPUT" printf '%s\n' "status=success" >> "$GITHUB_OUTPUT" echo "✅ ESLint fix completed. Files changed: $files_changed, Errors fixed: $errors_fixed" - name: Commit and Push Fixes if: inputs.mode == 'fix' && success() uses: stefanzweifel/git-auto-commit-action@04702edda442b2e678b25b537cec683a1493fcb9 # v7.1.0 with: commit_message: 'style: autofix ESLint violations' commit_user_name: ${{ inputs.username }} commit_user_email: ${{ inputs.email }} add_options: '-u'